# OpenAI SDK Integration Specification v2.1 - ADDENDUM

**Date:** 2026-01-14
**Status:** Critical Updates Based on Codebase Review
**Purpose:** Address 4 priority concerns identified during spec validation

---

## Priority 0: Keyring Cross-Platform Fallback Strategy

**Issue:** Spec assumes OS keyring is always available. In enterprise environments (Docker containers, CI/CD, headless servers, air-gapped systems), keyring backends may be unavailable, causing complete application failure.

**Severity:** SHOWSTOPPER without fallback

**Solution:** 4-tier fallback system with graceful degradation

---

### Tier 1: OS Native Keyring (Secure - Recommended)

**Platforms:**
- macOS: Keychain
- Windows: DPAPI (Credential Manager)
- Linux: Secret Service (Gnome Keyring, KWallet)

**Usage:** Automatic when available (existing spec implementation)

---

### Tier 2: Encrypted File Storage (Secure - Fallback)

**Use Cases:**
- Docker containers (minimal base images)
- CI/CD environments (headless)
- Kubernetes pods
- Air-gapped systems

**Implementation:**

```python
"""
Encrypted file storage backend for API keys.
Uses user-provided password for AES-256 encryption.
"""

import json
import os
from pathlib import Path
from typing import Optional
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2
import secrets
import base64

logger = logging.getLogger(__name__)


class EncryptedFileKeyStorage:
    """Encrypted file storage backend using AES-256-GCM."""

    def __init__(self, storage_path: Path, password: str):
        """
        Initialize encrypted storage.

        Args:
            storage_path: Path to encrypted keys file
            password: Encryption password (from env or prompt)
        """
        self.storage_path = storage_path
        self.password = password
        self._keys_cache = None

    def _derive_key(self, salt: bytes) -> bytes:
        """Derive encryption key from password using PBKDF2."""
        kdf = PBKDF2(
            algorithm=hashes.SHA256(),
            length=32,  # 256 bits for AES-256
            salt=salt,
            iterations=600_000,  # OWASP 2023 recommendation
        )
        return kdf.derive(self.password.encode('utf-8'))

    def _load_keystore(self) -> dict:
        """Load and decrypt keystore."""
        if not self.storage_path.exists():
            return {}

        try:
            with open(self.storage_path, 'rb') as f:
                data = f.read()

            # Format: salt (16 bytes) + nonce (12 bytes) + ciphertext + tag (16 bytes)
            salt = data[:16]
            nonce = data[16:28]
            ciphertext = data[28:]

            # Derive key and decrypt
            key = self._derive_key(salt)
            aesgcm = AESGCM(key)
            plaintext = aesgcm.decrypt(nonce, ciphertext, None)

            return json.loads(plaintext.decode('utf-8'))

        except Exception as e:
            logger.error(f"Failed to decrypt keystore: {e}")
            raise RuntimeError(
                "Failed to decrypt keys. "
                "Check KOLLABOR_KEY_ENCRYPTION_PASSWORD or delete keystore."
            )

    def _save_keystore(self, keystore: dict) -> None:
        """Encrypt and save keystore."""
        # Generate random salt and nonce
        salt = secrets.token_bytes(16)
        nonce = secrets.token_bytes(12)

        # Derive key and encrypt
        key = self._derive_key(salt)
        aesgcm = AESGCM(key)
        plaintext = json.dumps(keystore).encode('utf-8')
        ciphertext = aesgcm.encrypt(nonce, plaintext, None)

        # Write: salt + nonce + ciphertext+tag
        with open(self.storage_path, 'wb') as f:
            f.write(salt + nonce + ciphertext)

        # Set secure permissions
        self.storage_path.chmod(0o600)

    def store_key(self, profile_name: str, api_key: str) -> None:
        """Store encrypted API key."""
        keystore = self._load_keystore()
        keystore[profile_name] = api_key
        self._save_keystore(keystore)
        logger.info(f"Stored encrypted key for profile: {profile_name}")

    def get_key(self, profile_name: str) -> Optional[str]:
        """Retrieve decrypted API key."""
        keystore = self._load_keystore()
        return keystore.get(profile_name)

    def delete_key(self, profile_name: str) -> bool:
        """Delete API key."""
        keystore = self._load_keystore()
        if profile_name in keystore:
            del keystore[profile_name]
            self._save_keystore(keystore)
            logger.info(f"Deleted encrypted key for profile: {profile_name}")
            return True
        return False
```

**Configuration:**

```bash
# set encryption password via environment variable
export KOLLABOR_KEY_ENCRYPTION_PASSWORD="strong-password-here"

# or prompt on first use
kollabor --setup-encryption
```

---

### Tier 3: Environment Variables Only (Development/CI)

**Use Cases:**
- CI/CD pipelines
- Development environments
- Temporary testing

**Implementation:**

```python
class EnvironmentKeyStorage:
    """Environment variable-only storage (no persistence)."""

    def __init__(self):
        logger.warning(
            "Using environment variable storage for API keys. "
            "Keys are not persisted. This is insecure for production use."
        )

    def store_key(self, profile_name: str, api_key: str) -> None:
        """Cannot store in env-only mode."""
        raise RuntimeError(
            "Cannot persist keys in environment-only mode. "
            "Set keys via OPENAI_API_KEY or ANTHROPIC_API_KEY environment variables."
        )

    def get_key(self, profile_name: str) -> Optional[str]:
        """Get key from environment variables."""
        # Map profile name to env var
        env_vars = {
            "openai": "OPENAI_API_KEY",
            "anthropic": "ANTHROPIC_API_KEY",
            "azure": "AZURE_OPENAI_API_KEY",
        }

        # Try specific env var
        for prefix, var in env_vars.items():
            if prefix in profile_name.lower():
                return os.environ.get(var)

        # Try generic
        return os.environ.get(f"{profile_name.upper()}_API_KEY")

    def delete_key(self, profile_name: str) -> bool:
        """Cannot delete from environment."""
        return False
```

**Usage:**

```bash
export KOLLABOR_KEY_STORAGE_BACKEND=env_only
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...
```

---

### Tier 4: Plaintext Storage (Development Only - Discouraged)

**Use Cases:**
- Local development only
- Requires explicit opt-in

**Implementation:**

```python
class PlaintextKeyStorage:
    """Plaintext storage (INSECURE - development only)."""

    def __init__(self, storage_path: Path):
        self.storage_path = storage_path

        # Show scary warning
        logger.warning(
            "\n" + "="*70 + "\n"
            "WARNING: PLAINTEXT KEY STORAGE ENABLED\n"
            "API keys are stored WITHOUT encryption.\n"
            "This is INSECURE and should ONLY be used for development.\n"
            "NEVER use in production or commit to version control.\n"
            + "="*70
        )

    def store_key(self, profile_name: str, api_key: str) -> None:
        """Store plaintext key (INSECURE)."""
        keystore = self._load_keystore()
        keystore[profile_name] = api_key

        with open(self.storage_path, 'w') as f:
            json.dump(keystore, f, indent=2)

        self.storage_path.chmod(0o600)

        logger.warning(f"Stored PLAINTEXT key for {profile_name} (INSECURE)")

    def _load_keystore(self) -> dict:
        if not self.storage_path.exists():
            return {}
        with open(self.storage_path) as f:
            return json.load(f)

    def get_key(self, profile_name: str) -> Optional[str]:
        keystore = self._load_keystore()
        return keystore.get(profile_name)

    def delete_key(self, profile_name: str) -> bool:
        keystore = self._load_keystore()
        if profile_name in keystore:
            del keystore[profile_name]
            with open(self.storage_path, 'w') as f:
                json.dump(keystore, f, indent=2)
            return True
        return False
```

**Opt-in Required:**

```bash
# must explicitly enable
export KOLLABOR_ALLOW_PLAINTEXT_KEYS=true
export KOLLABOR_KEY_STORAGE_BACKEND=plaintext
```

---

### Updated APIKeyManager with Fallback Chain

```python
"""
API Key Manager with 4-tier fallback system.
Automatically selects best available backend.
"""

import os
import logging
from pathlib import Path
from typing import Optional

logger = logging.getLogger(__name__)


class APIKeyManager:
    """
    API key manager with automatic fallback chain.

    Fallback order:
    1. OS native keyring (secure, recommended)
    2. Encrypted file storage (secure, portable)
    3. Environment variables only (dev/ci)
    4. Plaintext storage (insecure, requires opt-in)
    """

    def __init__(self, storage_dir: Optional[Path] = None):
        """
        Initialize with automatic backend detection.

        Args:
            storage_dir: Directory for file-based storage
        """
        self.storage_dir = storage_dir or Path.home() / ".kollabor-cli"
        self.storage_dir.mkdir(parents=True, exist_ok=True)

        # Detect and initialize backend
        self.backend_type, self.backend = self._initialize_backend()

        logger.info(f"Using key storage backend: {self.backend_type}")

    def _initialize_backend(self) -> tuple[str, Any]:
        """
        Detect available backend with fallback chain.

        Returns:
            (backend_type, backend_instance)
        """
        # Check for forced backend
        forced = os.environ.get("KOLLABOR_KEY_STORAGE_BACKEND", "").lower()

        if forced == "keyring":
            return self._init_keyring()
        elif forced == "encrypted_file":
            return self._init_encrypted_file()
        elif forced == "env_only":
            return self._init_env_only()
        elif forced == "plaintext":
            return self._init_plaintext()

        # Automatic detection (fallback chain)

        # Tier 1: Try OS keyring
        try:
            return self._init_keyring()
        except Exception as e:
            logger.debug(f"OS keyring unavailable: {e}")

        # Tier 2: Try encrypted file if password available
        if os.environ.get("KOLLABOR_KEY_ENCRYPTION_PASSWORD"):
            try:
                return self._init_encrypted_file()
            except Exception as e:
                logger.debug(f"Encrypted file storage unavailable: {e}")

        # Tier 3: Environment variables only (if keys present)
        if (os.environ.get("OPENAI_API_KEY") or
            os.environ.get("ANTHROPIC_API_KEY")):
            return self._init_env_only()

        # Tier 4: Plaintext (if explicitly allowed)
        if os.environ.get("KOLLABOR_ALLOW_PLAINTEXT_KEYS", "").lower() in ("true", "1", "yes"):
            return self._init_plaintext()

        # No backend available
        raise RuntimeError(
            "No API key storage backend available.\n\n"
            "Options (in order of preference):\n"
            "1. Install keyring backend:\n"
            "   - macOS: Built-in (Keychain)\n"
            "   - Windows: Built-in (DPAPI)\n"
            "   - Linux: sudo apt install gnome-keyring OR python3-secretstorage\n\n"
            "2. Use encrypted file storage:\n"
            "   export KOLLABOR_KEY_ENCRYPTION_PASSWORD='your-password'\n\n"
            "3. Use environment variables (dev/ci only):\n"
            "   export OPENAI_API_KEY='sk-...'\n"
            "   export ANTHROPIC_API_KEY='sk-ant-...'\n\n"
            "4. Enable plaintext storage (INSECURE, development only):\n"
            "   export KOLLABOR_ALLOW_PLAINTEXT_KEYS=true\n"
        )

    def _init_keyring(self) -> tuple[str, Any]:
        """Initialize OS keyring backend."""
        import keyring
        keyring.get_keyring()  # Verify available

        from .keyring_storage import KeyringStorage
        return ("keyring", KeyringStorage())

    def _init_encrypted_file(self) -> tuple[str, Any]:
        """Initialize encrypted file backend."""
        password = os.environ.get("KOLLABOR_KEY_ENCRYPTION_PASSWORD")
        if not password:
            raise RuntimeError(
                "KOLLABOR_KEY_ENCRYPTION_PASSWORD not set for encrypted file storage"
            )

        storage_path = self.storage_dir / "keys.encrypted"
        return ("encrypted_file", EncryptedFileKeyStorage(storage_path, password))

    def _init_env_only(self) -> tuple[str, Any]:
        """Initialize environment variable backend."""
        return ("env_only", EnvironmentKeyStorage())

    def _init_plaintext(self) -> tuple[str, Any]:
        """Initialize plaintext backend (requires opt-in)."""
        storage_path = self.storage_dir / "keys.json"
        return ("plaintext", PlaintextKeyStorage(storage_path))

    def store_key(self, profile_name: str, api_key: str) -> None:
        """Store API key using current backend."""
        self.backend.store_key(profile_name, api_key)

    def get_key(self, profile_name: str) -> Optional[str]:
        """Retrieve API key using current backend."""
        return self.backend.get_key(profile_name)

    def delete_key(self, profile_name: str) -> bool:
        """Delete API key using current backend."""
        return self.backend.delete_key(profile_name)

    def get_backend_info(self) -> dict:
        """Get information about current backend."""
        return {
            "backend_type": self.backend_type,
            "secure": self.backend_type in ("keyring", "encrypted_file"),
            "persistent": self.backend_type in ("keyring", "encrypted_file", "plaintext"),
            "recommended_for_production": self.backend_type in ("keyring", "encrypted_file"),
        }
```

---

### Configuration Schema Updates

Add to `config.json` schema:

```json
{
  "security": {
    "key_storage_backend": "auto",
    "encrypted_file_path": "~/.kollabor-cli/keys.encrypted",
    "allow_plaintext_keys": false,
    "warn_insecure_storage": true
  }
}
```

**Configuration Options:**

- `key_storage_backend`: `"auto"` | `"keyring"` | `"encrypted_file"` | `"env_only"` | `"plaintext"`
- `encrypted_file_path`: Path to encrypted keystore (default: `~/.kollabor-cli/keys.encrypted`)
- `allow_plaintext_keys`: Enable plaintext storage (default: `false`)
- `warn_insecure_storage`: Show warnings for insecure backends (default: `true`)

---

### Environment Variables

```bash
# Backend selection
KOLLABOR_KEY_STORAGE_BACKEND=auto|keyring|encrypted_file|env_only|plaintext

# Encrypted file backend
KOLLABOR_KEY_ENCRYPTION_PASSWORD=your-strong-password

# Environment variable backend
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
AZURE_OPENAI_API_KEY=...

# Plaintext backend (requires explicit opt-in)
KOLLABOR_ALLOW_PLAINTEXT_KEYS=true|false

# Warnings
KOLLABOR_WARN_INSECURE_STORAGE=true|false
```

---

### Enterprise Deployment Guide

#### Docker Containers

**Option 1: Encrypted File Storage (Recommended)**

```dockerfile
FROM python:3.12-slim

# Install kollabor
RUN pip install kollabor

# Create secrets directory
RUN mkdir -p /run/secrets

# Run with encrypted storage
ENV KOLLABOR_KEY_STORAGE_BACKEND=encrypted_file
ENV KOLLABOR_KEY_ENCRYPTION_PASSWORD_FILE=/run/secrets/encryption_password

ENTRYPOINT ["kollabor"]
```

```bash
# docker-compose.yml
version: '3.8'
services:
  kollabor:
    build: .
    secrets:
      - encryption_password
    environment:
      - KOLLABOR_KEY_STORAGE_BACKEND=encrypted_file

secrets:
  encryption_password:
    file: ./secrets/encryption_password.txt
```

**Option 2: Environment Variables**

```dockerfile
FROM python:3.12-slim
RUN pip install kollabor

ENV KOLLABOR_KEY_STORAGE_BACKEND=env_only

ENTRYPOINT ["kollabor"]
```

```bash
docker run -e OPENAI_API_KEY=sk-... -e ANTHROPIC_API_KEY=sk-ant-... kollabor
```

---

#### Kubernetes

**Option 1: Encrypted File with Secret**

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: kollabor-encryption
type: Opaque
stringData:
  password: your-strong-password-here
---
apiVersion: v1
kind: Pod
metadata:
  name: kollabor
spec:
  containers:
  - name: kollabor
    image: kollabor:latest
    env:
    - name: KOLLABOR_KEY_STORAGE_BACKEND
      value: "encrypted_file"
    - name: KOLLABOR_KEY_ENCRYPTION_PASSWORD
      valueFrom:
        secretKeyRef:
          name: kollabor-encryption
          key: password
```

**Option 2: Environment Variables from Secret**

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: kollabor-api-keys
type: Opaque
stringData:
  openai-key: sk-...
  anthropic-key: sk-ant-...
---
apiVersion: v1
kind: Pod
metadata:
  name: kollabor
spec:
  containers:
  - name: kollabor
    image: kollabor:latest
    env:
    - name: KOLLABOR_KEY_STORAGE_BACKEND
      value: "env_only"
    - name: OPENAI_API_KEY
      valueFrom:
        secretKeyRef:
          name: kollabor-api-keys
          key: openai-key
    - name: ANTHROPIC_API_KEY
      valueFrom:
        secretKeyRef:
          name: kollabor-api-keys
          key: anthropic-key
```

---

#### CI/CD Pipelines

**GitHub Actions**

```yaml
name: Test with Kollabor
on: [push]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.12'

      - name: Install Kollabor
        run: pip install kollabor

      - name: Run tests
        env:
          KOLLABOR_KEY_STORAGE_BACKEND: env_only
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
        run: |
          kollabor "run integration tests"
```

**GitLab CI**

```yaml
test:
  image: python:3.12-slim
  variables:
    KOLLABOR_KEY_STORAGE_BACKEND: env_only
  script:
    - pip install kollabor
    - kollabor "run integration tests"
  secrets:
    OPENAI_API_KEY:
      vault: production/openai/api_key
    ANTHROPIC_API_KEY:
      vault: production/anthropic/api_key
```

---

#### AWS Lambda

```python
import os
import boto3
from kollabor import Kollabor

def lambda_handler(event, context):
    # Get encryption password from Secrets Manager
    secrets_client = boto3.client('secretsmanager')
    secret = secrets_client.get_secret_value(SecretId='kollabor-encryption-password')

    # Set environment variables
    os.environ['KOLLABOR_KEY_STORAGE_BACKEND'] = 'encrypted_file'
    os.environ['KOLLABOR_KEY_ENCRYPTION_PASSWORD'] = secret['SecretString']

    # Use Kollabor
    kollabor = Kollabor()
    result = kollabor.chat(event['prompt'])

    return {'statusCode': 200, 'body': result}
```

---

### Migration Strategy Update

Update **Phase 7: Configuration & Migration** with backend detection:

```python
class ConfigMigration:
    """Atomic configuration migration with backend detection."""

    def migrate(self) -> bool:
        """Migrate configuration and keys to v2.1."""
        try:
            # 1. Load and backup config
            config = self._load_config()
            self._create_backup(config)

            # 2. Migrate config structure
            migrated = self._migrate_v1_to_v2(config)

            # 3. Detect available key storage backend
            key_manager = APIKeyManager()
            backend_info = key_manager.get_backend_info()

            # 4. Show migration plan to user
            self._show_migration_plan(backend_info)

            # 5. Migrate keys based on backend
            if backend_info['backend_type'] == 'keyring':
                self._migrate_keys_to_keyring(migrated, key_manager)
            elif backend_info['backend_type'] == 'encrypted_file':
                self._migrate_keys_to_encrypted_file(migrated, key_manager)
            elif backend_info['backend_type'] == 'env_only':
                self._migrate_keys_to_env(migrated)
            else:
                # Plaintext - keys stay in config
                logger.warning("Keys remaining in config (plaintext backend)")

            # 6. Write migrated config
            self._save_config(migrated)

            logger.info("Migration completed successfully")
            return True

        except Exception as e:
            logger.error(f"Migration failed: {e}")
            self.rollback()
            return False

    def _show_migration_plan(self, backend_info: dict) -> None:
        """Show migration plan to user."""
        backend = backend_info['backend_type']
        secure = backend_info['secure']

        print(f"\nAPI Key Migration Plan:")
        print(f"  Backend: {backend}")
        print(f"  Secure: {'Yes' if secure else 'No'}")

        if not secure:
            print(f"\n  WARNING: Using {backend} storage")
            print(f"  Consider setting up secure storage:")
            if backend == 'env_only':
                print(f"    - Set KOLLABOR_KEY_ENCRYPTION_PASSWORD for encrypted file storage")
            elif backend == 'plaintext':
                print(f"    - Install keyring backend (recommended)")
                print(f"    - Or set KOLLABOR_KEY_ENCRYPTION_PASSWORD")
```

---

### Testing Strategy for Fallback Backends

Add to **Phase 10: Testing**:

```python
# tests/integration/test_key_storage_backends.py

import os
import pytest
from pathlib import Path
from core.llm.providers.security import APIKeyManager


class TestKeyStorageBackends:
    """Test all key storage backend fallback scenarios."""

    def test_keyring_backend_when_available(self):
        """Test keyring backend selection when available."""
        manager = APIKeyManager()
        assert manager.backend_type == "keyring"

        # Test store/retrieve
        manager.store_key("test-profile", "sk-test123")
        assert manager.get_key("test-profile") == "sk-test123"

        # Cleanup
        manager.delete_key("test-profile")

    def test_encrypted_file_backend_with_password(self, tmp_path):
        """Test encrypted file backend."""
        os.environ["KOLLABOR_KEY_STORAGE_BACKEND"] = "encrypted_file"
        os.environ["KOLLABOR_KEY_ENCRYPTION_PASSWORD"] = "test-password"

        manager = APIKeyManager(storage_dir=tmp_path)
        assert manager.backend_type == "encrypted_file"

        # Test store/retrieve
        manager.store_key("test-profile", "sk-test123")
        assert manager.get_key("test-profile") == "sk-test123"

        # Verify file is encrypted (not plaintext)
        encrypted_file = tmp_path / "keys.encrypted"
        with open(encrypted_file, 'rb') as f:
            content = f.read()
        assert b"sk-test123" not in content
        assert b"test-profile" not in content

    def test_encrypted_file_wrong_password_fails(self, tmp_path):
        """Test that wrong password fails to decrypt."""
        os.environ["KOLLABOR_KEY_ENCRYPTION_PASSWORD"] = "password1"

        manager1 = APIKeyManager(storage_dir=tmp_path)
        manager1.store_key("test", "sk-test123")

        # Try to read with wrong password
        os.environ["KOLLABOR_KEY_ENCRYPTION_PASSWORD"] = "wrong-password"
        manager2 = APIKeyManager(storage_dir=tmp_path)

        with pytest.raises(RuntimeError, match="Failed to decrypt"):
            manager2.get_key("test")

    def test_env_only_backend(self):
        """Test environment variable backend."""
        os.environ["KOLLABOR_KEY_STORAGE_BACKEND"] = "env_only"
        os.environ["OPENAI_API_KEY"] = "sk-test123"

        manager = APIKeyManager()
        assert manager.backend_type == "env_only"

        # Can retrieve
        assert manager.get_key("openai") == "sk-test123"

        # Cannot store
        with pytest.raises(RuntimeError, match="Cannot persist"):
            manager.store_key("test", "sk-new")

    def test_plaintext_backend_requires_opt_in(self, tmp_path):
        """Test plaintext backend requires explicit opt-in."""
        os.environ["KOLLABOR_KEY_STORAGE_BACKEND"] = "plaintext"
        os.environ.pop("KOLLABOR_ALLOW_PLAINTEXT_KEYS", None)

        # Should fail without opt-in
        with pytest.raises(RuntimeError, match="No API key storage"):
            APIKeyManager(storage_dir=tmp_path)

        # Should work with opt-in
        os.environ["KOLLABOR_ALLOW_PLAINTEXT_KEYS"] = "true"
        manager = APIKeyManager(storage_dir=tmp_path)
        assert manager.backend_type == "plaintext"

        # Verify warning was logged
        # (check logger mock)

    def test_automatic_fallback_chain(self, tmp_path, monkeypatch):
        """Test automatic backend selection fallback."""
        # Mock keyring unavailable
        def mock_import_keyring():
            raise ImportError("keyring not available")

        monkeypatch.setattr("keyring.get_keyring", mock_import_keyring)

        # No password set, no env vars, no opt-in
        os.environ.pop("KOLLABOR_KEY_ENCRYPTION_PASSWORD", None)
        os.environ.pop("OPENAI_API_KEY", None)
        os.environ.pop("KOLLABOR_ALLOW_PLAINTEXT_KEYS", None)

        # Should fail with helpful error
        with pytest.raises(RuntimeError, match="No API key storage backend available"):
            APIKeyManager(storage_dir=tmp_path)

    def test_docker_container_scenario(self, tmp_path):
        """Test typical Docker container setup."""
        # Simulate Docker: no keyring, password via secret
        os.environ["KOLLABOR_KEY_STORAGE_BACKEND"] = "encrypted_file"
        os.environ["KOLLABOR_KEY_ENCRYPTION_PASSWORD"] = "docker-secret-password"

        manager = APIKeyManager(storage_dir=tmp_path)
        assert manager.backend_type == "encrypted_file"

        # Store and retrieve
        manager.store_key("openai-prod", "sk-prod123")
        assert manager.get_key("openai-prod") == "sk-prod123"

    def test_ci_cd_scenario(self):
        """Test typical CI/CD setup."""
        # Simulate CI: env vars only
        os.environ["KOLLABOR_KEY_STORAGE_BACKEND"] = "env_only"
        os.environ["OPENAI_API_KEY"] = "sk-ci123"
        os.environ["ANTHROPIC_API_KEY"] = "sk-ant-ci123"

        manager = APIKeyManager()
        assert manager.backend_type == "env_only"

        # Can retrieve from env
        assert manager.get_key("openai") == "sk-ci123"
        assert manager.get_key("anthropic") == "sk-ant-ci123"

    def test_backend_info_reporting(self):
        """Test backend info is correctly reported."""
        os.environ["KOLLABOR_KEY_STORAGE_BACKEND"] = "keyring"

        manager = APIKeyManager()
        info = manager.get_backend_info()

        assert info["backend_type"] == "keyring"
        assert info["secure"] == True
        assert info["persistent"] == True
        assert info["recommended_for_production"] == True

    @pytest.mark.integration
    def test_migration_with_encrypted_backend(self, tmp_path):
        """Test migration to encrypted file backend."""
        # Create old config with plaintext keys
        old_config = {
            "llm_profiles": {
                "default": {
                    "api_key": "sk-old123",
                    "model": "gpt-4"
                }
            }
        }

        config_path = tmp_path / "config.json"
        with open(config_path, 'w') as f:
            json.dump(old_config, f)

        # Set up encrypted storage
        os.environ["KOLLABOR_KEY_ENCRYPTION_PASSWORD"] = "migration-test"

        # Run migration
        from core.config.migration import ConfigMigration
        migration = ConfigMigration(config_path, APIKeyManager(storage_dir=tmp_path))

        assert migration.migrate() == True

        # Verify key migrated to encrypted storage
        manager = APIKeyManager(storage_dir=tmp_path)
        assert manager.get_key("default") == "sk-old123"

        # Verify key removed from config
        with open(config_path) as f:
            new_config = json.load(f)
        assert new_config["llm_profiles"]["default"]["api_key"] == "[STORED_IN_KEYRING]"
```

---

### Documentation Updates

Add new sections to user documentation:

**docs/guides/key-storage.md:**

```markdown
# API Key Storage

Kollabor supports multiple API key storage backends with automatic fallback.

## Storage Backends (Priority Order)

### 1. OS Native Keyring (Recommended)

Most secure option using OS-managed credential storage.

**macOS:** Keychain (built-in)
**Windows:** Credential Manager / DPAPI (built-in)
**Linux:** Secret Service (requires gnome-keyring or similar)

Installation on Linux:
```bash
# Ubuntu/Debian
sudo apt install gnome-keyring

# Fedora
sudo dnf install gnome-keyring

# Arch
sudo pacman -S gnome-keyring
```

### 2. Encrypted File Storage

Secure portable storage using AES-256-GCM encryption.

Setup:
```bash
export KOLLABOR_KEY_ENCRYPTION_PASSWORD="your-strong-password"
```

Keys stored in: `~/.kollabor-cli/keys.encrypted`

### 3. Environment Variables

For CI/CD and development. Keys not persisted.

```bash
export OPENAI_API_KEY="sk-..."
export ANTHROPIC_API_KEY="sk-ant-..."
```

### 4. Plaintext Storage (Not Recommended)

Development only. Requires explicit opt-in.

```bash
export KOLLABOR_ALLOW_PLAINTEXT_KEYS=true
```

## Checking Your Backend

```bash
kollabor --key-storage-info
```

Output:
```
API Key Storage Backend:
  Type: keyring
  Secure: Yes
  Persistent: Yes
  Recommended for production: Yes
```

## Changing Backends

```bash
# Force specific backend
export KOLLABOR_KEY_STORAGE_BACKEND=encrypted_file
export KOLLABOR_KEY_ENCRYPTION_PASSWORD="password"

# Restart kollabor to apply
```

## Enterprise Deployment

See [Enterprise Deployment Guide](../deployment/enterprise.md) for:
- Docker container setup
- Kubernetes configurations
- CI/CD integration
- AWS Lambda deployment
```

---

### Timeline Impact

**Additional Work Required:**

- Encrypted file backend implementation: +2 days
- Environment variable backend: +1 day
- Plaintext backend: +0.5 days
- Backend detection logic: +1 day
- Testing all backends: +1.5 days
- Documentation: +1 day

**Total Additional Time:** +7 days

**Updated Timeline:**
- Original: 45-56 days (6.4-8.0 weeks)
- With fallback: 52-63 days (7.4-9.0 weeks)

**Realistic Estimate with Buffer:** 8-9 weeks

---

### Updated Phase 1: Foundation & Type System

Add encrypted file backend to Phase 1:

**Agent: FoundationSecurityEnhanced** (Day 3-7, was Day 3-5)
- Implement core/llm/providers/security.py (450-500 lines, was 350-400)
- APIKeyManager with 4-tier fallback
- KeyringStorage (OS native)
- EncryptedFileKeyStorage (AES-256-GCM)
- EnvironmentKeyStorage (env vars only)
- PlaintextKeyStorage (opt-in)
- APIKeyLoader with backend detection
- URLValidator with custom host support
- Deep LoggingRedactor with recursive redaction
- Unit tests (350+ lines, was 250+)
- Integration tests for all backends
- Coverage: 75%+

**Success Criteria:**
- All 4 backends work correctly
- Automatic fallback chain functions
- Encrypted file storage properly encrypts
- Environment variable backend reads keys
- Plaintext requires opt-in
- Migration works with all backends

---

## Summary: Priority 0 Resolution

**Status:** RESOLVED

**Changes Made:**
1. Added 4-tier fallback system (keyring → encrypted file → env vars → plaintext)
2. Implemented EncryptedFileKeyStorage with AES-256-GCM
3. Implemented EnvironmentKeyStorage for CI/CD
4. Implemented PlaintextKeyStorage with opt-in requirement
5. Updated APIKeyManager with automatic backend detection
6. Added enterprise deployment guides (Docker, Kubernetes, CI/CD, Lambda)
7. Added comprehensive testing strategy for all backends
8. Updated documentation with backend selection guide
9. Adjusted timeline: +7 days (now 8-9 weeks)

**Security Impact:**
- Tier 1 (keyring): A grade - secure
- Tier 2 (encrypted): A grade - secure
- Tier 3 (env vars): C grade - acceptable for dev/ci
- Tier 4 (plaintext): F grade - requires explicit opt-in

**Enterprise Readiness:** PASS

The fallback system ensures Kollabor works in all enterprise environments while maintaining security best practices.

---

## Priority 1: Resource Lifecycle Testing Methodology

**Issue:** Spec mentions memory leak tests but provides no methodology for detection.

**Solution:** Specify concrete testing tools and acceptance criteria.

---

### 6.1 Resource Testing Methodology

Add new section to testing strategy detailing leak detection approaches.

---

### Memory Leak Detection

**Tool 1: tracemalloc (Built-in - Recommended)**

```python
"""
Memory leak detection using Python's built-in tracemalloc.
Low overhead, suitable for integration tests.
"""

import tracemalloc
import gc
import pytest


@pytest.mark.resource_lifecycle
def test_provider_switching_no_memory_leak():
    """Test provider switching doesn't leak memory."""

    # Start tracing
    tracemalloc.start()

    # Get baseline after initial warmup
    for _ in range(10):
        await llm_service.switch_provider("openai")
        await llm_service.switch_provider("anthropic")

    # Force garbage collection
    gc.collect()
    gc.collect()  # Run twice for cyclic references

    # Capture baseline
    baseline_current, baseline_peak = tracemalloc.get_traced_memory()

    # Perform test operations
    for i in range(100):
        await llm_service.switch_provider("openai")
        await llm_service.switch_provider("anthropic")

        # Periodic GC during test
        if i % 20 == 0:
            gc.collect()

    # Final GC
    gc.collect()
    gc.collect()

    # Measure final memory
    current, peak = tracemalloc.get_traced_memory()

    # Calculate growth
    growth = current - baseline_current
    growth_mb = growth / 1024 / 1024

    # Stop tracing
    tracemalloc.stop()

    # Acceptance criteria: < 1MB growth for 100 operations
    assert growth < 1_000_000, (
        f"Memory leak detected: {growth_mb:.2f} MB growth "
        f"({growth:,} bytes) after 100 provider switches"
    )

    print(f"Memory growth: {growth_mb:.3f} MB (within acceptable range)")


@pytest.mark.resource_lifecycle
def test_long_running_session_memory():
    """Test memory doesn't grow unbounded in long sessions."""

    tracemalloc.start()

    # Warmup
    for _ in range(5):
        await llm_service.send_message("test message")
    gc.collect()

    baseline_current, _ = tracemalloc.get_traced_memory()

    # Simulate long session (1000 messages)
    for i in range(1000):
        await llm_service.send_message(f"message {i}")

        # GC every 100 messages
        if i % 100 == 0:
            gc.collect()

    gc.collect()
    gc.collect()

    current, _ = tracemalloc.get_traced_memory()
    growth = current - baseline_current
    growth_mb = growth / 1024 / 1024

    tracemalloc.stop()

    # Acceptance: < 10MB growth for 1000 messages
    assert growth < 10_000_000, (
        f"Memory leak in long session: {growth_mb:.2f} MB growth"
    )


def test_memory_snapshot_comparison():
    """Compare memory snapshots to identify leak sources."""

    tracemalloc.start()

    # Take snapshot before
    snapshot1 = tracemalloc.take_snapshot()

    # Perform operations
    for _ in range(100):
        await llm_service.send_message("test")

    gc.collect()

    # Take snapshot after
    snapshot2 = tracemalloc.take_snapshot()

    # Compare snapshots
    top_stats = snapshot2.compare_to(snapshot1, 'lineno')

    # Print top 10 memory growth sources
    print("\n[ Top 10 memory growth sources ]")
    for stat in top_stats[:10]:
        print(stat)

    # Check for suspicious growth patterns
    # (e.g., same file growing repeatedly)
    tracemalloc.stop()
```

**Tool 2: memray (Detailed Profiling)**

```bash
# Install memray
pip install memray

# Run test with memory profiling
memray run -o provider_switching.bin pytest tests/integration/test_resource_lifecycle.py::test_provider_switching_no_memory_leak

# Generate flamegraph
memray flamegraph provider_switching.bin -o provider_switching_flamegraph.html

# Generate table report
memray table provider_switching.bin

# Find leaked allocations
memray summary provider_switching.bin
```

```python
# Programmatic memray usage in tests
import memray

@pytest.mark.slow
def test_provider_switching_memray_profile():
    """Profile provider switching with memray."""

    with memray.Tracker("provider_switching.bin"):
        for _ in range(100):
            await llm_service.switch_provider("openai")
            await llm_service.switch_provider("anthropic")

    # Analyze output file separately
    # (memray doesn't provide programmatic analysis API in tests)
```

**Tool 3: pytest-monitor (Continuous Tracking)**

```toml
# pyproject.toml
[tool.pytest.ini_options]
addopts = [
    "--monitor-component", "memory,cpu",
    "--monitor-format", "json",
    "--monitor-output", "test_metrics.db"
]
```

```python
# Tests automatically tracked
@pytest.mark.monitor
def test_provider_operations():
    """Automatically monitored for memory/cpu."""
    for _ in range(100):
        await provider.send_request(...)
    # Metrics stored in test_metrics.db
```

```bash
# Query metrics
sqlite3 test_metrics.db "SELECT * FROM MONITOR WHERE test_name = 'test_provider_operations'"

# Compare runs
pytest-monitor compare --baseline run1.db --current run2.db
```

---

### File Descriptor Leak Detection

```python
"""
File descriptor leak detection using psutil.
Detects unclosed files, sockets, pipes.
"""

import psutil
import os
import pytest


@pytest.mark.resource_lifecycle
def test_no_file_descriptor_leak():
    """Test operations don't leak file descriptors."""

    process = psutil.Process(os.getpid())

    # Get baseline (after warmup)
    for _ in range(10):
        await llm_service.send_request("test")

    baseline_fds = process.num_fds()
    baseline_open_files = len(process.open_files())

    # Perform operations
    for _ in range(100):
        await llm_service.send_request("test message")

    # Measure final
    current_fds = process.num_fds()
    current_open_files = len(process.open_files())

    # Calculate growth
    fd_growth = current_fds - baseline_fds
    files_growth = current_open_files - baseline_open_files

    # Acceptance: < 5 FD growth (allowing for log rotation, temp files)
    assert fd_growth < 5, (
        f"File descriptor leak: {fd_growth} FDs leaked after 100 requests"
    )

    assert files_growth < 3, (
        f"Open files leak: {files_growth} files not closed"
    )


def test_connection_cleanup():
    """Test HTTP connections are properly closed."""

    process = psutil.Process(os.getpid())

    # Count initial connections
    initial_conns = [c for c in process.connections() if c.status == 'ESTABLISHED']
    baseline_count = len(initial_conns)

    # Perform operations
    for _ in range(100):
        await provider.send_request("test")

    # Wait for cleanup
    await asyncio.sleep(0.5)

    # Count final connections
    final_conns = [c for c in process.connections() if c.status == 'ESTABLISHED']
    current_count = len(final_conns)

    # Acceptance: connection pool size shouldn't grow unbounded
    # Allow for connection pooling (e.g., max 10 connections)
    assert current_count - baseline_count < 10, (
        f"Connection leak: {current_count - baseline_count} connections not closed"
    )


def test_detailed_fd_tracking():
    """Track specific FD types during operations."""

    process = psutil.Process(os.getpid())

    def categorize_fds():
        """Categorize open file descriptors."""
        files = process.open_files()
        conns = process.connections()

        return {
            'regular_files': len([f for f in files if not f.path.startswith('/dev')]),
            'sockets': len(conns),
            'pipes': len([f for f in files if 'pipe' in f.path.lower()]),
            'other': process.num_fds() - len(files) - len(conns)
        }

    # Baseline
    baseline = categorize_fds()

    # Operations
    for _ in range(100):
        await llm_service.send_request("test")

    # Final
    final = categorize_fds()

    # Report growth by category
    for category in baseline:
        growth = final[category] - baseline[category]
        print(f"{category}: {baseline[category]} -> {final[category]} (+{growth})")

        # Category-specific assertions
        if category == 'regular_files':
            assert growth < 3, f"File leak: {growth} files not closed"
        elif category == 'sockets':
            assert growth < 10, f"Socket leak: {growth} sockets not closed"
```

---

### Connection Pool Leak Detection

```python
"""
Connection pool leak detection using mocks and counters.
Verifies all acquired connections are returned.
"""

import pytest
from unittest.mock import patch, Mock


@pytest.mark.resource_lifecycle
async def test_connection_pool_cleanup():
    """Test connection pool returns all connections."""

    # Track connection acquisitions and releases
    acquired_count = 0
    released_count = 0

    original_acquire = provider.session.connector.acquire
    original_release = provider.session.connector.release

    async def tracked_acquire(*args, **kwargs):
        nonlocal acquired_count
        acquired_count += 1
        return await original_acquire(*args, **kwargs)

    async def tracked_release(*args, **kwargs):
        nonlocal released_count
        released_count += 1
        return await original_release(*args, **kwargs)

    # Patch methods
    with patch.object(provider.session.connector, 'acquire', tracked_acquire), \
         patch.object(provider.session.connector, 'release', tracked_release):

        # Perform operations
        for _ in range(100):
            await provider.send_request("test")

        # Allow cleanup
        await asyncio.sleep(0.5)

        # Verify all connections returned
        assert acquired_count == released_count, (
            f"Connection leak: acquired {acquired_count}, "
            f"released {released_count} "
            f"(diff: {acquired_count - released_count})"
        )


async def test_connection_pool_size_bounded():
    """Test connection pool doesn't grow unbounded."""

    # Get connection pool
    pool = provider.session.connector

    # Perform many concurrent requests
    tasks = []
    for _ in range(500):
        task = provider.send_request("test")
        tasks.append(task)

    # Wait for all
    await asyncio.gather(*tasks)

    # Check pool size
    if hasattr(pool, '_acquired'):
        active = len(pool._acquired)
        assert active == 0, f"Connection pool has {active} connections not returned"

    # Check pool limit respected
    if hasattr(pool, '_limit'):
        assert pool._limit <= 100, f"Connection pool limit too high: {pool._limit}"
```

---

### Thread Leak Detection

```python
"""
Thread leak detection using threading module.
Detects orphaned threads that aren't cleaned up.
"""

import threading
import pytest


@pytest.mark.resource_lifecycle
def test_no_thread_leak():
    """Test operations don't create orphaned threads."""

    # Get baseline thread count
    baseline_threads = threading.active_count()
    baseline_names = [t.name for t in threading.enumerate()]

    # Perform operations
    for _ in range(100):
        await llm_service.send_request("test")

    # Allow cleanup
    await asyncio.sleep(1.0)

    # Get final thread count
    current_threads = threading.active_count()
    current_names = [t.name for t in threading.enumerate()]

    # Calculate growth
    thread_growth = current_threads - baseline_threads

    # Find new threads
    new_threads = set(current_names) - set(baseline_names)

    # Acceptance: < 2 thread growth (allow for event loop threads)
    assert thread_growth < 2, (
        f"Thread leak: {thread_growth} threads created "
        f"(new threads: {new_threads})"
    )


def test_thread_pool_cleanup():
    """Test thread pool executors are properly shut down."""

    from concurrent.futures import ThreadPoolExecutor

    # Track active executors
    executors_created = []

    original_init = ThreadPoolExecutor.__init__
    original_shutdown = ThreadPoolExecutor.shutdown

    def tracked_init(self, *args, **kwargs):
        executors_created.append(self)
        return original_init(self, *args, **kwargs)

    shutdown_count = 0
    def tracked_shutdown(self, *args, **kwargs):
        nonlocal shutdown_count
        shutdown_count += 1
        return original_shutdown(self, *args, **kwargs)

    with patch.object(ThreadPoolExecutor, '__init__', tracked_init), \
         patch.object(ThreadPoolExecutor, 'shutdown', tracked_shutdown):

        # Perform operations that might use thread pools
        for _ in range(10):
            await llm_service.send_request("test")

        # Cleanup
        await llm_service.cleanup()

        # Verify all executors shut down
        assert shutdown_count == len(executors_created), (
            f"Thread pool leak: {len(executors_created)} executors created, "
            f"{shutdown_count} shut down"
        )
```

---

### Acceptance Criteria

Add to success criteria section:

**Resource Lifecycle Acceptance Criteria:**

```
Memory Leaks:
- < 1 MB growth per 100 provider switches
- < 10 MB growth per 1000 messages in long session
- < 5 MB growth per 100 streaming responses

File Descriptors:
- < 5 FD growth per 100 requests
- < 3 open file growth per 100 requests
- Connection pool size < 100 at all times

Connections:
- All acquired connections must be released
- < 10 active connections after operations complete
- No connections in CLOSE_WAIT or TIME_WAIT state

Threads:
- < 2 thread growth per 100 operations
- All thread pools must be shut down properly
- No daemon threads left running after cleanup

Test Methodology:
- Use tracemalloc for memory profiling
- Use psutil for FD/process monitoring
- Use threading module for thread tracking
- Run tests with pytest-monitor for trends
- Profile with memray for detailed analysis
```

---

### CI Integration

Add to CI configuration:

```yaml
# .github/workflows/resource-tests.yml
name: Resource Lifecycle Tests

on: [push, pull_request]

jobs:
  resource_tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.12'

      - name: Install dependencies
        run: |
          pip install -e ".[dev]"
          pip install pytest-monitor memray psutil

      - name: Run resource lifecycle tests
        run: |
          pytest tests/integration/test_resource_lifecycle.py \
            --monitor-component memory,cpu \
            -v \
            --tb=short

      - name: Memory profiling with memray
        run: |
          memray run pytest tests/integration/test_resource_lifecycle.py \
            -k "provider_switching"
          memray summary memray-pytest-*.bin

      - name: Upload memory profiles
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: memory-profiles
          path: memray-pytest-*.bin

      - name: Compare with baseline
        run: |
          pytest-monitor compare \
            --baseline metrics/baseline.db \
            --current .pytest-monitor.db \
            --fail-on-regression
```

---

### Documentation

Add to testing docs:

**docs/testing/resource-lifecycle-testing.md:**

```markdown
# Resource Lifecycle Testing

Testing for memory leaks, FD leaks, and other resource issues.

## Running Resource Tests

```bash
# All resource lifecycle tests
pytest tests/integration/test_resource_lifecycle.py

# Specific test
pytest tests/integration/test_resource_lifecycle.py::test_no_memory_leak

# With memory profiling
memray run pytest tests/integration/test_resource_lifecycle.py
memray flamegraph memray-pytest-*.bin
```

## Tools

### tracemalloc (Built-in)
- Memory profiling with low overhead
- Suitable for integration tests
- Provides snapshots and comparisons

### memray
- Detailed memory profiling
- Flamegraph visualization
- Identifies allocation sources

### psutil
- Process monitoring
- FD tracking
- Connection monitoring

### pytest-monitor
- Continuous metric tracking
- Trend analysis
- Regression detection

## Interpreting Results

### Memory Growth
- Acceptable: < 1 MB per 100 operations
- Warning: 1-5 MB per 100 operations
- Leak: > 5 MB per 100 operations

### File Descriptors
- Acceptable: < 5 FD growth
- Warning: 5-10 FD growth
- Leak: > 10 FD growth

### Threads
- Acceptable: < 2 thread growth
- Warning: 2-5 thread growth
- Leak: > 5 thread growth
```

---

## Summary: Priority 1 Resolution

**Status:** RESOLVED

**Changes Made:**
1. Added tracemalloc methodology for memory leak detection
2. Added memray profiling for detailed analysis
3. Added psutil for FD/process monitoring
4. Added threading module for thread leak detection
5. Specified concrete acceptance criteria for each resource type
6. Added CI integration with pytest-monitor
7. Created documentation for resource testing

**Tools Specified:**
- Primary: tracemalloc (built-in, low overhead)
- Secondary: memray (detailed profiling)
- Monitoring: psutil (FD/process tracking)
- Continuous: pytest-monitor (trend tracking)

**Acceptance Criteria:**
- Memory: < 1 MB / 100 ops
- FDs: < 5 / 100 ops
- Connections: all released
- Threads: < 2 growth

**Timeline Impact:** No change (testing already scheduled in Phase 10)

---

## Priority 2: Tool Accumulator Migration Validation

**Issue:** Spec shows tool accumulator integration but doesn't validate against current implementation. Current codebase uses batch retrieval pattern, spec proposes incremental accumulation.

**Solution:** Phased migration approach with wrapper pattern to reduce risk.

---

### Current Implementation Analysis

**Verified from codebase:**

```python
# core/llm/llm_service.py - Current pattern (5 call sites found)

# Call site 1: Line 1107
tool_calls = self.api_service.get_last_tool_calls()

# Call site 2: Line 1621
raw_tool_calls = self.api_service.get_last_tool_calls()

# Call site 3: Line 1653
tool_calls = self.api_service.get_last_tool_calls()
msg = self.api_service.format_tool_result(tool_id, result, error)

# Call site 4: Line 1867
raw_tool_calls = self.api_service.get_last_tool_calls()

# Call site 5: Line 1899
tool_calls = self.api_service.get_last_tool_calls()
msg = self.api_service.format_tool_result(tool_id, result, error)
```

**Current Flow:**
1. APICommunicationService streams response
2. Tool calls accumulated internally during parsing
3. After streaming complete, batch retrieval via `get_last_tool_calls()`
4. LLMService processes all tool calls
5. Results formatted via `format_tool_result()`

**Proposed Flow (from spec):**
1. Provider streams response chunks
2. LLMService explicitly accumulates tool deltas
3. Accumulator assembles complete tool calls incrementally
4. On completion, retrieve completed tools
5. Execute and format results

---

### Migration Strategy: 3-Phase Approach

#### Phase 6a: Wrapper Interface (Low Risk)

**Objective:** Create abstraction without changing behavior

**Implementation:**

```python
"""
Tool accumulator wrapper for gradual migration.
Phase 6a: Wraps existing api_service pattern
Phase 6b: Switches to explicit accumulation
"""

from typing import List, Dict, Any, Optional


class ToolCallAccumulator:
    """
    Tool call accumulator with two modes:
    - Legacy mode: wraps api_service.get_last_tool_calls()
    - Explicit mode: accumulates deltas during streaming
    """

    def __init__(self, mode: str = "legacy"):
        """
        Initialize accumulator.

        Args:
            mode: "legacy" or "explicit"
        """
        self.mode = mode
        self._accumulated_tools = {}
        self._completed_tools = []

    # ===== Explicit Accumulation Mode (Phase 6b) =====

    def add_delta(
        self,
        tool_call_id: str,
        name: Optional[str] = None,
        arguments_delta: Optional[str] = None
    ) -> None:
        """
        Add incremental tool call delta (explicit mode).

        Args:
            tool_call_id: Unique tool call identifier
            name: Tool name (on first delta)
            arguments_delta: Incremental JSON arguments
        """
        if self.mode != "explicit":
            raise RuntimeError("add_delta only available in explicit mode")

        if tool_call_id not in self._accumulated_tools:
            self._accumulated_tools[tool_call_id] = {
                "id": tool_call_id,
                "name": name or "",
                "arguments": ""
            }

        # Update name if provided
        if name:
            self._accumulated_tools[tool_call_id]["name"] = name

        # Append arguments delta
        if arguments_delta:
            self._accumulated_tools[tool_call_id]["arguments"] += arguments_delta

    def finalize(self) -> None:
        """
        Finalize accumulated tools (parse JSON arguments).
        Called when streaming completes.
        """
        if self.mode != "explicit":
            return

        import json
        self._completed_tools = []

        for tool_id, tool_data in self._accumulated_tools.items():
            try:
                # Parse accumulated JSON arguments
                arguments = json.loads(tool_data["arguments"])

                self._completed_tools.append({
                    "id": tool_id,
                    "name": tool_data["name"],
                    "input": arguments
                })
            except json.JSONDecodeError as e:
                logger.error(f"Failed to parse tool arguments for {tool_id}: {e}")
                # Add error tool call
                self._completed_tools.append({
                    "id": tool_id,
                    "name": tool_data["name"],
                    "input": {},
                    "error": f"Invalid JSON arguments: {e}"
                })

    # ===== Legacy Wrapper Mode (Phase 6a) =====

    def set_completed_tools(self, tools: List[Dict[str, Any]]) -> None:
        """
        Set completed tools (legacy mode wrapper).

        Args:
            tools: Tool calls from api_service.get_last_tool_calls()
        """
        if self.mode != "legacy":
            raise RuntimeError("set_completed_tools only available in legacy mode")

        self._completed_tools = tools

    # ===== Common Interface =====

    def get_completed_tools(self) -> List[Dict[str, Any]]:
        """
        Get completed tool calls.

        Returns:
            List of tool call dictionaries
        """
        return self._completed_tools

    def has_completed_tools(self) -> bool:
        """Check if any tools completed."""
        return len(self._completed_tools) > 0

    def clear(self) -> None:
        """Clear accumulated state."""
        self._accumulated_tools.clear()
        self._completed_tools.clear()


# ===== Integration into LLMService =====

class LLMService:
    """LLM service with tool accumulator."""

    def __init__(self, ...):
        # ... existing init

        # Create accumulator in legacy mode (Phase 6a)
        # Switch to explicit mode in Phase 6b
        self.tool_accumulator = ToolCallAccumulator(mode="legacy")

    async def _handle_streaming_chunk(self, chunk_data: dict) -> None:
        """Handle streaming chunk (current signature)."""
        # ... existing streaming logic

        # Phase 6a: After streaming completes, populate accumulator
        # (No behavior change, just wrapping)
        if self._response_complete:
            tool_calls = self.api_service.get_last_tool_calls()
            self.tool_accumulator.set_completed_tools(tool_calls)

    async def _execute_pending_tools(self) -> None:
        """Execute pending tool calls."""
        # ===== UPDATE ALL 5 CALL SITES TO USE ACCUMULATOR =====

        # OLD (5 locations):
        # tool_calls = self.api_service.get_last_tool_calls()

        # NEW (unified):
        tool_calls = self.tool_accumulator.get_completed_tools()

        if not tool_calls:
            return

        # ... existing tool execution logic

        # Clear after execution
        self.tool_accumulator.clear()
```

**Changes Required (Phase 6a):**
- Add `ToolCallAccumulator` class (100 lines)
- Initialize in `LLMService.__init__` (1 line)
- Populate accumulator after streaming (3 lines)
- Update 5 call sites to use accumulator (5 lines changed)

**Risk:** LOW (wrapper preserves exact behavior)

**Testing:**
- All existing tests should pass unchanged
- Add accumulator wrapper tests
- Verify behavior identical before/after

---

#### Phase 6b: Explicit Accumulation (Medium Risk)

**Objective:** Switch to explicit accumulation during streaming

**Implementation:**

```python
class LLMService:
    """LLM service with explicit tool accumulation."""

    def __init__(self, ...):
        # ... existing init

        # Switch to explicit mode
        self.tool_accumulator = ToolCallAccumulator(mode="explicit")

    async def _handle_streaming_chunk(self, chunk_data: dict) -> None:
        """Handle streaming chunk with explicit tool accumulation."""
        # ... existing text streaming logic

        # NEW: Explicit tool delta accumulation
        if "tool_call" in chunk_data:
            tool_data = chunk_data["tool_call"]

            # Check for tool call delta
            if "delta" in tool_data:
                delta = tool_data["delta"]

                self.tool_accumulator.add_delta(
                    tool_call_id=delta.get("id", ""),
                    name=delta.get("name"),
                    arguments_delta=delta.get("arguments", "")
                )

        # On completion, finalize tools
        if self._response_complete:
            self.tool_accumulator.finalize()

    async def _execute_pending_tools(self) -> None:
        """Execute pending tool calls (no change from Phase 6a)."""
        # Same unified interface
        tool_calls = self.tool_accumulator.get_completed_tools()

        # ... existing execution logic

        self.tool_accumulator.clear()
```

**Changes Required (Phase 6b):**
- Switch accumulator to explicit mode (1 line)
- Add tool delta handling in streaming (10-15 lines)
- Remove batch retrieval from api_service (remove code)

**Risk:** MEDIUM (changes streaming flow)

**Testing:**
- Compare tool calls before/after migration
- Test incremental JSON accumulation
- Test malformed JSON handling
- Verify Question Gate still works

---

#### Phase 6c: Provider Abstraction (High Risk)

**Objective:** Use provider's native streaming format

**Implementation:**

```python
class LLMService:
    """LLM service with provider-specific tool streaming."""

    async def _call_llm(self) -> str:
        """Make LLM API call with provider streaming."""
        self.tool_accumulator.clear()

        # Use provider's streaming interface
        async for chunk in self.current_provider.chat_completion_streaming(
            messages=self.conversation_history,
            model=self.config.get("core.llm.model"),
            stream=True,
            tools=self.native_tools
        ):
            # Handle different chunk types
            if chunk.delta.type == "text":
                # Display text
                await self._display_text_chunk(chunk.delta.text)

            elif chunk.delta.type == "tool_call_delta":
                # Accumulate tool delta
                self.tool_accumulator.add_delta(
                    tool_call_id=chunk.delta.tool_call_id,
                    name=chunk.delta.tool_name,
                    arguments_delta=chunk.delta.tool_arguments_delta
                )

            elif chunk.delta.type == "thinking":
                # Handle thinking blocks (Anthropic)
                await self._display_thinking_chunk(chunk.delta.thinking)

            # Check for completion
            if chunk.is_final:
                self.tool_accumulator.finalize()
                break

        # Execute accumulated tools
        if self.tool_accumulator.has_completed_tools():
            await self._execute_pending_tools()
```

**Changes Required (Phase 6c):**
- Update `_call_llm()` to use provider streaming (30-40 lines)
- Handle UnifiedResponse chunk types (20-30 lines)
- Update text/thinking display logic (15-20 lines)

**Risk:** HIGH (major refactoring of streaming flow)

**Testing:**
- Test with both OpenAI and Anthropic providers
- Test tool calling with each provider
- Test mixed content (text + tools + thinking)
- Test cancellation during streaming
- Test error recovery

---

### Migration Timeline

**Phase 6 Breakdown (was 5-6 days, now 7-8 days):**

**Days 1-2: Phase 6a - Wrapper Implementation**
- Implement ToolCallAccumulator in legacy mode
- Add to LLMService initialization
- Update 5 call sites to use accumulator
- Run existing test suite (should pass unchanged)
- Add wrapper-specific tests

**Day 3: Phase 6a - Validation**
- Extensive testing with existing functionality
- Compare tool execution before/after wrapper
- Verify Question Gate compatibility
- Verify MCP tool integration

**Days 4-5: Phase 6b - Explicit Accumulation**
- Switch accumulator to explicit mode
- Add delta handling to streaming
- Remove batch retrieval from api_service
- Test incremental accumulation
- Compare results with Phase 6a

**Day 6: Phase 6b - Validation**
- Test with complex tool scenarios
- Test malformed JSON handling
- Test streaming interruption/cancellation
- Performance testing

**Days 7-8: Phase 6c - Provider Abstraction**
- Update `_call_llm()` for provider streaming
- Implement UnifiedResponse chunk handling
- Update display logic for new format
- Comprehensive testing with both providers

**Success Criteria:**
- All existing tests pass after Phase 6a
- Tool execution identical before/after Phase 6b
- Both providers work correctly in Phase 6c
- No regressions in Question Gate
- MCP tools still function

---

### Comparison: Current vs. Proposed

**Current Pattern (Batch Retrieval):**

Advantages:
- Simple, well-tested
- All parsing in one place (api_service)
- Easy to understand

Disadvantages:
- Tightly coupled to api_service
- Hard to support multiple providers
- Less visibility into streaming process

**Proposed Pattern (Explicit Accumulation):**

Advantages:
- Provider-agnostic
- Better separation of concerns
- More control over accumulation
- Easier to debug

Disadvantages:
- More complex
- Requires careful migration
- More code in llm_service

**Verdict:** Proposed pattern better for multi-provider, worth migration cost

---

### Risk Mitigation

**Mitigation Strategies:**

1. **Phased Rollout:**
   - Phase 6a first (wrapper, low risk)
   - Extensive testing before Phase 6b
   - Can stay on Phase 6a if issues arise

2. **Feature Flag:**
   ```python
   # Add to config
   "core.llm.use_explicit_tool_accumulation": False  # Phase 6a
   # "core.llm.use_explicit_tool_accumulation": True   # Phase 6b

   # In LLMService
   mode = "explicit" if self.config.get("core.llm.use_explicit_tool_accumulation") else "legacy"
   self.tool_accumulator = ToolCallAccumulator(mode=mode)
   ```

3. **Comparison Testing:**
   ```python
   # Run both patterns, compare results
   def test_tool_accumulation_equivalence():
       # Run with legacy mode
       accumulator_legacy = ToolCallAccumulator(mode="legacy")
       results_legacy = run_test_with_accumulator(accumulator_legacy)

       # Run with explicit mode
       accumulator_explicit = ToolCallAccumulator(mode="explicit")
       results_explicit = run_test_with_accumulator(accumulator_explicit)

       # Compare
       assert results_legacy == results_explicit
   ```

4. **Rollback Plan:**
   - Keep api_service batch retrieval code until Phase 6c complete
   - Can switch back to legacy mode via config
   - Git tags for each phase

---

### Testing Strategy

**Unit Tests:**
```python
# tests/unit/test_tool_accumulator.py

def test_legacy_mode_batch_tools():
    """Test legacy mode wraps batch retrieval."""
    accumulator = ToolCallAccumulator(mode="legacy")

    tools = [
        {"id": "1", "name": "get_weather", "input": {"city": "SF"}},
        {"id": "2", "name": "get_time", "input": {}}
    ]

    accumulator.set_completed_tools(tools)

    assert accumulator.get_completed_tools() == tools
    assert accumulator.has_completed_tools()


def test_explicit_mode_incremental_json():
    """Test explicit mode accumulates JSON incrementally."""
    accumulator = ToolCallAccumulator(mode="explicit")

    # Simulate streaming JSON in chunks
    accumulator.add_delta("tool_1", name="get_weather")
    accumulator.add_delta("tool_1", arguments_delta='{"ci')
    accumulator.add_delta("tool_1", arguments_delta='ty": "S')
    accumulator.add_delta("tool_1", arguments_delta='F"}')

    accumulator.finalize()

    tools = accumulator.get_completed_tools()
    assert len(tools) == 1
    assert tools[0]["name"] == "get_weather"
    assert tools[0]["input"] == {"city": "SF"}


def test_explicit_mode_malformed_json():
    """Test explicit mode handles malformed JSON gracefully."""
    accumulator = ToolCallAccumulator(mode="explicit")

    accumulator.add_delta("tool_1", name="test")
    accumulator.add_delta("tool_1", arguments_delta='{"invalid')

    accumulator.finalize()

    tools = accumulator.get_completed_tools()
    assert len(tools) == 1
    assert "error" in tools[0]


def test_mode_separation():
    """Test legacy and explicit modes can't be mixed."""
    accumulator = ToolCallAccumulator(mode="legacy")

    with pytest.raises(RuntimeError, match="only available in explicit mode"):
        accumulator.add_delta("tool_1", name="test")

    accumulator = ToolCallAccumulator(mode="explicit")

    with pytest.raises(RuntimeError, match="only available in legacy mode"):
        accumulator.set_completed_tools([])
```

**Integration Tests:**
```python
# tests/integration/test_tool_accumulation_migration.py

@pytest.mark.parametrize("mode", ["legacy", "explicit"])
async def test_tool_execution_both_modes(mode):
    """Test tool execution works in both modes."""
    # Configure accumulator mode
    config.set("core.llm.tool_accumulator_mode", mode)

    llm_service = create_llm_service(config)

    # Send message that triggers tool call
    await llm_service.send_message("What's the weather in SF?")

    # Verify tool was executed
    assert llm_service.tool_executor.stats["total_executions"] > 0


async def test_migration_equivalence():
    """Test legacy and explicit modes produce same results."""
    # Same message, both modes
    message = "Get weather in SF and NY"

    # Legacy mode
    config_legacy = create_config(tool_accumulator_mode="legacy")
    llm_legacy = create_llm_service(config_legacy)
    result_legacy = await llm_legacy.send_message(message)

    # Explicit mode
    config_explicit = create_config(tool_accumulator_mode="explicit")
    llm_explicit = create_llm_service(config_explicit)
    result_explicit = await llm_explicit.send_message(message)

    # Compare tool executions
    assert llm_legacy.tool_executor.stats["total_executions"] == \
           llm_explicit.tool_executor.stats["total_executions"]

    # Compare results (may differ in whitespace, normalize)
    assert normalize_text(result_legacy) == normalize_text(result_explicit)
```

---

### Updated Phase 6 Timeline

**Phase 6: LLMService Integration (was 5-6 days, now 7-8 days)**

**Agent: LLMServiceProviderIntegration** (Day 1-3)
- Modify core/llm/llm_service.py
- Add ProviderRegistry and APIKeyLoader
- Add ToolCallAccumulator (Phase 6a - legacy mode)
- Provider initialization on startup
- Profile switching with locking
- Update 5 call sites to use accumulator
- Tests (250+ lines)

**Agent: LLMServiceToolMigration** (Day 4-6, NEW)
- Switch to explicit accumulation (Phase 6b)
- Add delta handling to streaming
- Remove batch retrieval
- Feature flag for rollback
- Comparison testing
- Tests (180+ lines)

**Agent: LLMServiceTesting** (Day 7-8)
- End-to-end LLM service tests (300+ lines)
- Provider switching during conversation
- Tool execution with both providers (both modes)
- Error handling and recovery
- Conversation persistence
- Coverage: 70%+

---

## Summary: Priority 2 Resolution

**Status:** RESOLVED

**Changes Made:**
1. Analyzed current implementation (5 call sites identified)
2. Designed 3-phase migration approach
3. Created ToolCallAccumulator with dual modes
4. Specified wrapper pattern for Phase 6a (low risk)
5. Specified explicit accumulation for Phase 6b (medium risk)
6. Specified provider abstraction for Phase 6c (high risk)
7. Added feature flag for rollback capability
8. Added comparison testing strategy
9. Updated Phase 6 timeline: +2 days

**Migration Phases:**
- Phase 6a: Wrapper (days 1-3, low risk)
- Phase 6b: Explicit (days 4-6, medium risk)
- Phase 6c: Provider (days 7-8, high risk)

**Risk Mitigation:**
- Feature flag for rollback
- Comparison testing between modes
- Can stay on Phase 6a if issues arise
- Keep legacy code until Phase 6c complete

**Timeline Impact:**
- Phase 6: 5-6 days → 7-8 days (+2 days)
- Total: 52-63 days → 54-65 days (7.7-9.3 weeks)

**Updated Estimate:** 8-9 weeks (unchanged with buffer)

---

## Priority 3: Provider Registry Integration Verification

**Issue:** Spec shows decorator-based provider registry but doesn't show concrete integration into LLMService.

**Solution:** Document exact integration points with before/after code.

---

### Current Architecture Verified

**From codebase analysis:**

```python
# core/llm/llm_service.py (lines 172-186)

# Get active profile
if self.profile_manager:
    api_profile = self.profile_manager.get_active_profile()
else:
    # Fallback to default
    from .profile_manager import LLMProfile
    api_profile = LLMProfile(
        name="default",
        api_url="http://localhost:1234",
        model="default",
        temperature=0.7,
    )

# Initialize API communication service
self.api_service = APICommunicationService(config, self.raw_conversations_dir, api_profile)

# Link session ID
self.api_service.set_session_id(self.conversation_logger.session_id)
```

**Single Primary Call Site:**

```python
# core/llm/llm_service.py (line 2248)

return await self.api_service.call_llm(
    conversation_history=self.conversation_history,
    max_history=self.max_history,
    streaming_callback=self._handle_streaming_chunk,
    tools=self.native_tools
)
```

**Profile Update Method:**

```python
# core/llm/api_communication_service.py (line 93-100)

def update_from_profile(self, profile: LLMProfile) -> None:
    """Update API settings from a profile."""
    # Uses profile getter methods that resolve env var -> config -> default
    self.api_url = profile.get_api_url()
    self.api_key = profile.get_api_key()
    self.model = profile.get_model()
    # ... other settings
```

---

### Integration Strategy: Clean Replacement

The integration is remarkably clean due to single responsibility design:

**Before (Current):**
- `APICommunicationService`: Handles HTTP, request formatting, response parsing
- Initialized once with profile
- Single call site for API invocation
- Profile updates via `update_from_profile()`

**After (With Registry):**
- `ProviderRegistry`: Manages provider instances
- `OpenAIProvider` / `AnthropicProvider`: Handle provider-specific logic
- Same initialization location
- Same single call site
- Profile updates trigger provider switch

---

### Concrete Integration: Before & After

#### Current Implementation

```python
class LLMService:
    """Current implementation."""

    def __init__(self, config, event_bus, renderer, profile_manager=None, ...):
        # ... init code (lines 106-169)

        # ===== CURRENT: Single API service =====
        # Lines 172-186
        if self.profile_manager:
            api_profile = self.profile_manager.get_active_profile()
        else:
            api_profile = self._create_default_profile()

        self.api_service = APICommunicationService(
            config,
            self.raw_conversations_dir,
            api_profile
        )

        self.api_service.set_session_id(self.conversation_logger.session_id)
        # ===== END CURRENT =====

        # ... rest of init (lines 190-249)

    async def _call_llm(self) -> str:
        """Current API call."""
        # ===== CURRENT: Direct api_service call =====
        # Line 2248
        return await self.api_service.call_llm(
            conversation_history=self.conversation_history,
            max_history=self.max_history,
            streaming_callback=self._handle_streaming_chunk,
            tools=self.native_tools
        )
        # ===== END CURRENT =====

    def reload_config(self) -> None:
        """Current config reload."""
        # ===== CURRENT: Update api_service =====
        # Lines 2297-2298
        self.api_service.enable_streaming = self.config.get(
            "core.llm.enable_streaming", False
        )
        # ===== END CURRENT =====
```

#### New Implementation with Registry

```python
class LLMService:
    """New implementation with provider registry."""

    def __init__(self, config, event_bus, renderer, profile_manager=None, ...):
        # ... init code (lines 106-169) [UNCHANGED]

        # ===== NEW: Provider registry initialization =====
        from .providers import ProviderRegistry, ProviderConfig, APIKeyManager

        # Initialize key manager (with fallback)
        self.key_manager = APIKeyManager()

        # Initialize provider registry
        self.provider_registry = ProviderRegistry()

        # Get active profile
        if self.profile_manager:
            api_profile = self.profile_manager.get_active_profile()
        else:
            api_profile = self._create_default_profile()

        # Create provider configuration
        provider_config = ProviderConfig.from_profile(
            profile=api_profile,
            key_manager=self.key_manager
        )

        # Create provider instance
        self.current_provider = self.provider_registry.create_provider(
            provider_config
        )

        # Initialize provider
        await self.current_provider.initialize()

        # Store current profile for switching
        self.current_profile = api_profile

        # Backward compatibility: keep api_service reference for Anthropic
        # This allows gradual migration of code that uses api_service
        if provider_config.provider == ProviderType.ANTHROPIC:
            self.api_service = self.current_provider.api_service
        else:
            self.api_service = None  # Not available for OpenAI
        # ===== END NEW =====

        # ... rest of init (lines 190-249) [MOSTLY UNCHANGED]
        # Note: tool_executor, mcp_integration, etc. remain the same

    async def _call_llm(self) -> str:
        """New API call through provider."""
        # Reset streaming state
        self._streaming_buffer = ""
        self._in_thinking = False
        self._thinking_buffer = ""
        self._last_chunk_position = 0
        self._response_started = False

        # Check for cancellation
        if self.cancel_processing:
            logger.info("API call cancelled before starting")
            raise asyncio.CancelledError("Request cancelled by user")

        # ===== NEW: Provider-based call =====
        try:
            # Prepare messages (unchanged)
            messages = self.conversation_history[-self.max_history:]

            # Call provider's streaming method
            response_text = ""
            async for chunk in self.current_provider.chat_completion_streaming(
                messages=[msg.to_dict() for msg in messages],
                model=self.config.get("core.llm.model"),
                stream=True,
                tools=self.native_tools
            ):
                # Handle streaming chunk (unified format)
                await self._handle_unified_streaming_chunk(chunk)

                # Accumulate text
                if chunk.delta.type == "text":
                    response_text += chunk.delta.text

                # Check for cancellation
                if self.cancel_processing:
                    raise asyncio.CancelledError("Cancelled by user")

            return response_text

        except asyncio.CancelledError:
            logger.info("LLM API call was cancelled")
            self._cleanup_streaming_state()
            raise
        except Exception as e:
            logger.error(f"LLM API call failed: {e}")
            self._cleanup_streaming_state()
            raise
        # ===== END NEW =====

    async def _handle_unified_streaming_chunk(self, chunk: StreamingResponse) -> None:
        """
        Handle unified streaming chunk from provider.

        Args:
            chunk: Unified StreamingResponse from provider
        """
        # Handle different chunk types
        if chunk.delta.type == "text":
            # Text delta - display
            await self._display_text_chunk(chunk.delta.text)

        elif chunk.delta.type == "thinking":
            # Thinking block - Anthropic specific
            await self._display_thinking_chunk(chunk.delta.thinking)

        elif chunk.delta.type == "tool_call_delta":
            # Tool call delta - accumulate
            self.tool_accumulator.add_delta(
                tool_call_id=chunk.delta.tool_call_id,
                name=chunk.delta.tool_name,
                arguments_delta=chunk.delta.tool_arguments_delta
            )

        # Check for completion
        if chunk.is_final:
            self.tool_accumulator.finalize()

    async def switch_provider(self, profile_name: str) -> bool:
        """
        Switch to a different provider/profile.

        Args:
            profile_name: Name of profile to switch to

        Returns:
            True if switched successfully
        """
        try:
            # Get new profile
            new_profile = self.profile_manager.get_profile(profile_name)
            if not new_profile:
                logger.error(f"Profile not found: {profile_name}")
                return False

            # Clean up current provider
            await self.current_provider.cleanup()

            # Create new provider
            provider_config = ProviderConfig.from_profile(
                profile=new_profile,
                key_manager=self.key_manager
            )

            self.current_provider = self.provider_registry.create_provider(
                provider_config
            )

            # Initialize new provider
            await self.current_provider.initialize()

            # Update current profile
            self.current_profile = new_profile

            # Update backward compatibility reference
            if provider_config.provider == ProviderType.ANTHROPIC:
                self.api_service = self.current_provider.api_service
            else:
                self.api_service = None

            logger.info(f"Switched to provider: {provider_config.provider.value}")
            return True

        except Exception as e:
            logger.error(f"Failed to switch provider: {e}")
            return False

    def reload_config(self) -> None:
        """Reload configuration with provider support."""
        logger.info("Hot reloading LLM configuration...")

        # Reload LLM settings [UNCHANGED]
        self.max_history = self.config.get("core.llm.max_history", 90)

        # Reload tool executor timeouts [UNCHANGED]
        self.tool_executor.terminal_timeout = self.config.get("core.llm.terminal_timeout", 120)
        self.tool_executor.mcp_timeout = self.config.get("core.llm.mcp_timeout", 120)

        # ===== NEW: Reload provider settings =====
        # Streaming setting
        streaming_enabled = self.config.get("core.llm.enable_streaming", False)

        # Update provider configuration
        if hasattr(self.current_provider, 'update_config'):
            self.current_provider.update_config({
                'enable_streaming': streaming_enabled
            })

        # Backward compatibility
        if self.api_service:
            self.api_service.enable_streaming = streaming_enabled
        # ===== END NEW =====

        logger.info(f"Config reloaded: max_history={self.max_history}, "
                   f"terminal_timeout={self.tool_executor.terminal_timeout}, "
                   f"mcp_timeout={self.tool_executor.mcp_timeout}, "
                   f"streaming={streaming_enabled}")

    async def cleanup(self) -> None:
        """Cleanup with provider support."""
        logger.info("Cleaning up LLM service...")

        # ===== NEW: Cleanup provider =====
        if hasattr(self, 'current_provider') and self.current_provider:
            await self.current_provider.cleanup()
        # ===== END NEW =====

        # OLD: Cleanup api_service [REMOVED if using OpenAI]
        # if self.api_service:
        #     await self.api_service.shutdown()

        logger.info("LLM service cleanup complete")
```

---

### Key Integration Points Summary

**1. Initialization (lines 172-186):**
- Replace `APICommunicationService` instantiation
- Add `ProviderRegistry` and `APIKeyManager`
- Create provider from profile using `ProviderConfig`
- Keep backward compatibility via `api_service` reference

**2. API Call (line 2248):**
- Replace `api_service.call_llm()` call
- Use `provider.chat_completion_streaming()`
- Add `_handle_unified_streaming_chunk()` method

**3. Profile Switching (new method):**
- Add `switch_provider()` method
- Cleanup old provider, initialize new one
- Atomic operation with error handling

**4. Config Reload (lines 2297-2298):**
- Update provider configuration dynamically
- Maintain backward compatibility

**5. Cleanup (new location):**
- Add provider cleanup in `cleanup()` method
- Replace api_service shutdown

---

### Changes Summary

**Files Modified:**
- `core/llm/llm_service.py`:
  - __init__: ~30 lines changed
  - _call_llm: ~40 lines changed
  - _handle_unified_streaming_chunk: ~30 lines new
  - switch_provider: ~40 lines new
  - reload_config: ~10 lines changed
  - cleanup: ~5 lines changed
  - **Total: ~155 lines changed/added**

**Files Created:**
- `core/llm/providers/` (entire directory, per spec)

**Backward Compatibility:**
- Anthropic provider wraps APICommunicationService
- `self.api_service` reference maintained for Anthropic
- Existing code using api_service continues to work
- Gradual migration path available

---

### Minimal Changes Required

**Absolute minimum for Phase 6:**

```python
# In __init__, replace lines 172-186 with:
from .providers import ProviderRegistry, ProviderConfig, APIKeyManager

self.key_manager = APIKeyManager()
self.provider_registry = ProviderRegistry()

api_profile = self.profile_manager.get_active_profile() if self.profile_manager else self._create_default_profile()
provider_config = ProviderConfig.from_profile(api_profile, self.key_manager)

self.current_provider = self.provider_registry.create_provider(provider_config)
await self.current_provider.initialize()

# Backward compatibility
self.api_service = self.current_provider.api_service if hasattr(self.current_provider, 'api_service') else None

# In _call_llm, replace line 2248 with:
async for chunk in self.current_provider.chat_completion_streaming(
    messages=[msg.to_dict() for msg in self.conversation_history[-self.max_history:]],
    model=self.config.get("core.llm.model"),
    stream=True,
    tools=self.native_tools
):
    # Process chunk (reuse existing streaming logic initially)
    await self._handle_streaming_chunk(chunk.to_dict())  # Convert to old format temporarily
```

**That's it for basic functionality!**

Additional features (profile switching, config reload updates) can be added incrementally.

---

### Testing Strategy

**Unit Tests:**
```python
# tests/unit/test_llm_service_provider_integration.py

def test_provider_initialization():
    """Test provider is initialized from profile."""
    llm_service = create_llm_service()

    assert llm_service.current_provider is not None
    assert llm_service.provider_registry is not None


def test_provider_type_detection():
    """Test correct provider type selected."""
    # OpenAI profile
    profile_openai = create_profile(api_key="sk-proj-...")
    llm_service = create_llm_service(profile=profile_openai)

    assert isinstance(llm_service.current_provider, OpenAIProvider)

    # Anthropic profile
    profile_anthropic = create_profile(api_key="sk-ant-...")
    llm_service = create_llm_service(profile=profile_anthropic)

    assert isinstance(llm_service.current_provider, AnthropicProvider)


async def test_provider_switch():
    """Test switching between providers."""
    llm_service = create_llm_service()

    # Start with Anthropic
    assert llm_service.current_profile.name == "anthropic-default"

    # Switch to OpenAI
    success = await llm_service.switch_provider("openai-gpt4")
    assert success
    assert llm_service.current_profile.name == "openai-gpt4"
    assert isinstance(llm_service.current_provider, OpenAIProvider)


async def test_backward_compatibility():
    """Test api_service reference maintained for Anthropic."""
    profile = create_profile(provider="anthropic")
    llm_service = create_llm_service(profile=profile)

    # api_service should be available
    assert llm_service.api_service is not None
    assert llm_service.api_service is llm_service.current_provider.api_service
```

**Integration Tests:**
```python
# tests/integration/test_provider_integration_e2e.py

async def test_openai_provider_end_to_end():
    """Test complete flow with OpenAI provider."""
    profile = create_openai_profile()
    llm_service = create_llm_service(profile=profile)

    # Send message
    await llm_service.send_message("Hello")

    # Verify provider was used
    assert llm_service.current_provider.request_count > 0


async def test_anthropic_provider_end_to_end():
    """Test complete flow with Anthropic provider."""
    profile = create_anthropic_profile()
    llm_service = create_llm_service(profile=profile)

    # Send message
    await llm_service.send_message("Hello")

    # Verify provider was used
    assert llm_service.current_provider.request_count > 0


async def test_provider_switch_mid_conversation():
    """Test switching providers during conversation."""
    llm_service = create_llm_service()

    # Start conversation with Anthropic
    await llm_service.send_message("Hello")
    assert len(llm_service.conversation_history) == 2  # user + assistant

    # Switch to OpenAI
    await llm_service.switch_provider("openai-gpt4")

    # Continue conversation
    await llm_service.send_message("Tell me more")
    assert len(llm_service.conversation_history) == 4  # preserved

    # Verify OpenAI provider used for second message
    assert isinstance(llm_service.current_provider, OpenAIProvider)
```

---

## Summary: Priority 3 Resolution

**Status:** RESOLVED

**Changes Made:**
1. Verified current architecture (single instantiation, single call site)
2. Documented exact integration points with line numbers
3. Created before/after code showing concrete changes
4. Specified minimal changes required (~155 lines)
5. Added provider switching method
6. Maintained backward compatibility via api_service reference
7. Added testing strategy for integration

**Integration Points:**
- __init__: 30 lines changed (provider creation)
- _call_llm: 40 lines changed (provider streaming)
- _handle_unified_streaming_chunk: 30 lines new (chunk handling)
- switch_provider: 40 lines new (profile switching)
- reload_config: 10 lines changed (provider updates)
- cleanup: 5 lines changed (provider cleanup)

**Total Changes:** ~155 lines in llm_service.py

**Backward Compatibility:** Maintained via api_service reference

**Risk:** MEDIUM (well-defined interface, single call site makes integration clean)

**Timeline Impact:** No change (already accounted for in Phase 6)

---

## Final Summary: All 4 Priorities Resolved

### Priority 0: Keyring Fallback ✅ RESOLVED
- Added 4-tier fallback system
- Enterprise deployment guides
- Timeline impact: +7 days

### Priority 1: Resource Testing Methodology ✅ RESOLVED
- Specified tracemalloc, memray, psutil tools
- Concrete acceptance criteria
- Timeline impact: None

### Priority 2: Tool Accumulator Migration ✅ RESOLVED
- 3-phase migration approach
- Wrapper pattern for low-risk migration
- Timeline impact: +2 days

### Priority 3: Provider Registry Integration ✅ RESOLVED
- Concrete before/after code
- Exact integration points verified
- Timeline impact: None

**Updated Total Timeline:**
- Original: 45-56 days (6.4-8.0 weeks)
- With P0: 52-63 days (7.4-9.0 weeks)
- With P2: 54-65 days (7.7-9.3 weeks)
- **Final Estimate: 8-9 weeks**

**Implementation Risk:**
- Original: MEDIUM
- Updated: MEDIUM-LOW (better mitigation strategies)

**Enterprise Readiness:**
- Original: CONDITIONAL (keyring-only)
- Updated: FULL (4-tier fallback)

**All concerns addressed. Spec ready for implementation.**

---

**End of Addendum**

Apply these updates to the main specification v2.1 before beginning implementation.
