# Story 2.5: Provider OpenAI (LLM Cloud)

Status: done

<!-- Note: Validation is optional. Run validate-create-story for quality check before dev-story. -->

## Story

As a **system**,
I want **to integrate OpenAI API as an LLM provider**,
so that **Pro users can translate documents with GPT models**.

## Acceptance Criteria

1. **AC1: API Integration** - Given `OPENAI_API_KEY` is configured in environment, when `OpenAIProvider.translate_text()` is called, then text is translated using GPT-4 or specified model
2. **AC2: Custom System Prompt** - Custom system prompt can be injected via request metadata to guide translation context
3. **AC3: Rate Limiting** - API rate limits return error "PROVIDER_RATE_LIMITED" with retry suggestion (HTTP 429)
4. **AC4: Invalid Key Handling** - Invalid API key returns error "OPENAI_INVALID_KEY" with HTTP 401
5. **AC5: Graceful Error Handling** - All errors return structured JSON (never HTTP 500) with French messages
6. **AC6: Health Check** - Provider `is_available()` returns `True` when API key is valid and service is reachable
7. **AC7: Registry Integration** - Provider is registered in `ProviderRegistry` and appears in fallback chain
8. **AC8: Unit Tests** - Tests verify all error scenarios, rate limiting handling, and mock OpenAI API responses

## Tasks / Subtasks

- [x] **Task 1: Create OpenAI Provider Implementation** (AC: 1, 2)
  - [x] 1.1 Create `services/providers/openai_provider.py`
  - [x] 1.2 Implement `OpenAITranslationProvider` class extending `TranslationProvider`
  - [x] 1.3 Implement `translate_text()` using OpenAI Chat Completions API
  - [x] 1.4 Support custom system prompt injection via request metadata
  - [x] 1.5 Configure default translation system prompt with temperature 0.3

- [x] **Task 2: Implement Error Handling** (AC: 3, 4, 5)
  - [x] 2.1 Define error codes: `OPENAI_RATE_LIMITED`, `OPENAI_INVALID_KEY`, `OPENAI_QUOTA_EXCEEDED`, `OPENAI_TIMEOUT`, `OPENAI_SERVICE_ERROR`, `OPENAI_CONTEXT_TOO_LONG`
  - [x] 2.2 Implement `OpenAIProviderError` exception class (follow Ollama pattern)
  - [x] 2.3 Map OpenAI API errors to structured error responses with French messages
  - [x] 2.4 Add retry logic with exponential backoff for rate limits and timeouts
  - [x] 2.5 Add timeout configuration (default 60s for OpenAI - faster than Ollama)
  - [x] 2.6 Handle specific OpenAI errors: rate_limit_exceeded, insufficient_quota, invalid_api_key

- [x] **Task 3: Implement Health Check** (AC: 6)
  - [x] 3.1 Implement `is_available()` to validate API key and service reachability
  - [x] 3.2 Add `health_check()` with caching (TTL 60s) matching existing provider pattern
  - [x] 3.3 Make lightweight API call to verify credentials (e.g., list models or simple completion)
  - [x] 3.4 Return `ProviderHealthStatus` with availability, latency, and model info

- [x] **Task 4: Registry Integration** (AC: 7)
  - [x] 4.1 Add `register_openai_provider()` function
  - [x] 4.2 Add `get_openai_provider()` singleton function
  - [x] 4.3 Update `services/providers/__init__.py` to auto-register OpenAI when `OPENAI_ENABLED=true`
  - [x] 4.4 Verify provider appears in fallback chain when configured

- [x] **Task 5: Configuration Updates** (AC: 1, 2)
  - [x] 5.1 Verify `OPENAI_API_KEY`, `OPENAI_MODEL`, `OPENAI_ENABLED` in `config.py` (already present)
  - [x] 5.2 Add OpenAI-specific configuration options to `config.py`:
    - `OPENAI_TIMEOUT=60` (faster than Ollama's 120s)
    - `OPENAI_MAX_RETRIES=3`
    - `OPENAI_RETRY_DELAY=1.0`
    - `OPENAI_BASE_URL` (optional, for custom endpoints like Azure OpenAI)
  - [x] 5.3 Update `.env.example` with OpenAI-specific config

- [x] **Task 6: Create Unit Tests** (AC: 8)
  - [x] 6.1 Create `tests/test_providers/test_openai_provider.py`
  - [x] 6.2 Test successful translation with mocked OpenAI API
  - [x] 6.3 Test all error scenarios (rate limited, invalid key, quota exceeded, timeout)
  - [x] 6.4 Test custom system prompt injection
  - [x] 6.5 Test retry logic for rate limits
  - [x] 6.6 Test health check functionality
  - [x] 6.7 Test registry integration

- [x] **Task 7: Update Documentation** (AC: 1-8)
  - [x] 7.1 Update `services/providers/README.md` with OpenAI section
  - [x] 7.2 Document OpenAI setup requirements (API key from platform.openai.com)
  - [x] 7.3 Document supported models and pricing considerations
  - [x] 7.4 Document rate limiting behavior and retry strategy

## Dev Notes

### OpenAI API Specifics

**OpenAI Chat Completions API:**

| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/v1/chat/completions` | POST | Generate translation |
| `/v1/models` | GET | List available models (for health check) |

**API Request Format:**

```python
OPENAI_API_URL = "https://api.openai.com/v1/chat/completions"

headers = {
    "Authorization": f"Bearer {OPENAI_API_KEY}",
    "Content-Type": "application/json"
}

payload = {
    "model": "gpt-4o-mini",  # or gpt-4, gpt-3.5-turbo
    "messages": [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": text_to_translate}
    ],
    "temperature": 0.3,  # Lower for consistent translation
    "max_tokens": 4096   # Adjust based on expected output
}
```

**API Response Format:**
```json
{
  "id": "chatcmpl-abc123",
  "object": "chat.completion",
  "created": 1677652288,
  "model": "gpt-4o-mini",
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "Bonjour, comment allez-vous?"
    },
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 50,
    "completion_tokens": 10,
    "total_tokens": 60
  }
}
```

**OpenAI Error Codes:**

| OpenAI Error | HTTP | Mapped Code | French Message |
|--------------|------|-------------|----------------|
| `rate_limit_exceeded` | 429 | `OPENAI_RATE_LIMITED` | "Limite de requêtes OpenAI atteinte. Réessayez dans {retry_after}s." |
| `insufficient_quota` | 429 | `OPENAI_QUOTA_EXCEEDED` | "Quota OpenAI épuisé. Vérifiez votre facturation." |
| `invalid_api_key` | 401 | `OPENAI_INVALID_KEY` | "Clé API OpenAI invalide. Vérifiez votre configuration." |
| `context_length_exceeded` | 400 | `OPENAI_CONTEXT_TOO_LONG` | "Texte trop long (max {max_tokens} tokens)." |
| `server_error` | 500 | `OPENAI_SERVICE_ERROR` | "Service OpenAI temporairement indisponible." |
| Timeout | - | `OPENAI_TIMEOUT` | "Délai d'attente OpenAI dépassé." |

### Recommended Models for Translation

| Model | Cost | Speed | Quality | Best For |
|-------|------|-------|---------|----------|
| `gpt-4o-mini` | $0.15/M tokens | Fast | Good | Default choice, cost-effective |
| `gpt-4o` | $2.50/M tokens | Medium | Excellent | High-quality requirements |
| `gpt-4` | $30/M tokens | Slower | Excellent | Critical translations |
| `gpt-3.5-turbo` | $0.50/M tokens | Fastest | Good | Speed priority |

**Default:** `gpt-4o-mini` (best value for translation)

### Default System Prompt for Translation

```python
DEFAULT_TRANSLATION_PROMPT = """You are a professional translator. Translate the following text from {source_lang} to {target_lang}.

Rules:
- Translate ONLY the text, do not add explanations or notes
- Preserve the original formatting, line breaks, and structure
- Maintain the original tone and style
- For technical terms, use the standard translation in the target language
- If the text contains proper nouns or brand names, keep them unchanged unless there's a well-known translation"""

def _build_system_prompt(
    source_lang: str, 
    target_lang: str, 
    custom_prompt: Optional[str] = None
) -> str:
    if custom_prompt:
        return custom_prompt
    return DEFAULT_TRANSLATION_PROMPT.format(
        source_lang=source_lang, 
        target_lang=target_lang
    )
```

### Architecture Compliance

Per `_bmad-output/planning-artifacts/architecture.md`:

**Error Format:**
```json
{
  "error": "OPENAI_RATE_LIMITED",
  "message": "Limite de requêtes OpenAI atteinte. Réessayez dans 20s.",
  "details": {
    "provider": "openai",
    "retry_after_seconds": 20,
    "model": "gpt-4o-mini"
  }
}
```

**Never return HTTP 500** - All errors must be 4xx or 502 (upstream error).

**Naming Conventions:**
- File: `openai_provider.py` (snake_case)
- Class: `OpenAITranslationProvider` (PascalCase)
- Error codes: `OPENAI_*` (UPPER_SNAKE_CASE)
- JSON fields: snake_case

### Previous Story Intelligence (Story 2.4 - Ollama)

**What Worked Well:**
- `httpx` library for HTTP requests (supports async and sync)
- Error codes with `to_dict()` method for consistent formatting
- Retry logic with exponential backoff for transient errors
- Health check with 60s TTL caching
- Thread-safe singleton pattern for provider instance
- Structlog-compatible logging with keyword args
- Language name mapping for better LLM understanding

**Patterns to Reuse:**
```python
# Error codes pattern
OPENAI_RATE_LIMITED = "OPENAI_RATE_LIMITED"
OPENAI_INVALID_KEY = "OPENAI_INVALID_KEY"
OPENAI_QUOTA_EXCEEDED = "OPENAI_QUOTA_EXCEEDED"
OPENAI_TIMEOUT = "OPENAI_TIMEOUT"
OPENAI_SERVICE_ERROR = "OPENAI_SERVICE_ERROR"
OPENAI_CONTEXT_TOO_LONG = "OPENAI_CONTEXT_TOO_LONG"

_RETRYABLE_ERRORS = {OPENAI_RATE_LIMITED, OPENAI_TIMEOUT, OPENAI_SERVICE_ERROR}

# Exception class pattern
class OpenAIProviderError(Exception):
    def __init__(self, code: str, message: str, details: Optional[Dict[str, Any]] = None):
        self.code = code
        self.message = message
        self.details = details or {}
        super().__init__(message)

    def to_dict(self) -> Dict[str, Any]:
        result = {"error": self.code, "message": self.message}
        if self.details:
            result["details"] = self.details
        return result

# Retry logic pattern
def _translate_with_retry(self, text: str, system_prompt: str) -> str:
    last_error = None
    for attempt in range(self.max_retries + 1):
        try:
            return self._make_api_request(text, system_prompt)
        except OpenAIProviderError as e:
            last_error = e
            if e.code not in _RETRYABLE_ERRORS or attempt == self.max_retries:
                raise
            delay = self.retry_delay * (2 ** attempt)
            time.sleep(delay)
    raise last_error
```

**Key Differences from Ollama:**
- Requires API key authentication (Bearer token)
- Uses OpenAI's specific error codes and headers
- Rate limiting is more strict (pay-per-use)
- Faster response times (60s timeout vs 120s)
- No model "pulling" concept - models are always available
- Quota management is critical (billing impact)

### File Structure

**Files to Create:**
- `services/providers/openai_provider.py` - Main OpenAI provider implementation
- `tests/test_providers/test_openai_provider.py` - Unit tests

**Files to Modify:**
- `services/providers/__init__.py` - Add OpenAI auto-registration
- `services/providers/config.py` - Add OPENAI_TIMEOUT, OPENAI_MAX_RETRIES, OPENAI_RETRY_DELAY, OPENAI_BASE_URL
- `.env.example` - Add OpenAI-specific configuration options
- `services/providers/README.md` - Add OpenAI documentation

### Error Codes to Implement

| Code | HTTP | Scenario | Message Template |
|------|------|----------|------------------|
| `OPENAI_RATE_LIMITED` | 429 | Rate limit hit | "Limite de requêtes atteinte. Réessayez dans {retry_after}s." |
| `OPENAI_INVALID_KEY` | 401 | Invalid API key | "Clé API invalide. Vérifiez OPENAI_API_KEY." |
| `OPENAI_QUOTA_EXCEEDED` | 429 | Billing quota exceeded | "Quota épuisé. Vérifiez votre facturation OpenAI." |
| `OPENAI_TIMEOUT` | 502 | Request timeout | "Délai dépassé. Le service est lent." |
| `OPENAI_SERVICE_ERROR` | 502 | OpenAI server error | "Service temporairement indisponible." |
| `OPENAI_CONTEXT_TOO_LONG` | 413 | Context exceeds model limit | "Texte trop long (max {max_tokens} tokens)." |

### Configuration

**Environment Variables (`.env.example`):**
```bash
# OpenAI Provider (Cloud LLM)
OPENAI_ENABLED=true
OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxxxxxxxxxx
OPENAI_MODEL=gpt-4o-mini
OPENAI_TIMEOUT=60
OPENAI_MAX_RETRIES=3
OPENAI_RETRY_DELAY=1.0
# OPENAI_BASE_URL=https://api.openai.com/v1  # Optional: for Azure OpenAI or proxies
```

**Provider Config (`services/providers/config.py`):**
Add to existing OpenAI section:
```python
OPENAI_TIMEOUT: int = int(os.getenv("OPENAI_TIMEOUT", "60"))
OPENAI_MAX_RETRIES: int = int(os.getenv("OPENAI_MAX_RETRIES", "3"))
OPENAI_RETRY_DELAY: float = float(os.getenv("OPENAI_RETRY_DELAY", "1.0"))
OPENAI_BASE_URL: str = os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
```

### Testing Strategy

**Unit Tests (Mocked):**
- Mock `httpx` or `requests` responses
- Test successful translation
- Test all error scenarios (rate limit, invalid key, quota exceeded, timeout)
- Test custom system prompt injection
- Test health check logic
- Test retry logic for rate limits
- Test registry integration

**Test Commands:**
```bash
# Unit tests only
pytest tests/test_providers/test_openai_provider.py -v

# All provider tests
pytest tests/test_providers/ -v

# With coverage
pytest tests/test_providers/ --cov=services/providers -v
```

### Logging Pattern

```python
try:
    import structlog
    logger = structlog.get_logger(__name__)
    _HAS_STRUCTLOG = True
except ImportError:
    import logging
    logger = logging.getLogger(__name__)
    _HAS_STRUCTLOG = False

def _log_info(event: str, **kwargs):
    """Log info with structlog or standard logging compatibility."""
    if _HAS_STRUCTLOG:
        logger.info(event, **kwargs)
    else:
        msg = f"{event} " + " ".join(f"{k}={v}" for k, v in kwargs.items())
        logger.info(msg)

# Good - metadata only (NO document content)
_log_info(
    "openai_translation_success",
    chars=len(text),
    source_lang=source_language,
    target_lang=target_language,
    model=self._model,
    latency_ms=round(latency * 1000, 2),
    tokens_used=response.usage.total_tokens,
)

_log_error(
    "openai_translation_failed",
    error_code=error.code,
    text_length=len(text),
    source_lang=source_language,
    target_lang=target_language,
    model=self._model,
)
```

### Dependencies

**Internal:**
- `services/providers/base.py` - TranslationProvider abstract class
- `services/providers/registry.py` - ProviderRegistry
- `services/providers/config.py` - Configuration
- `services/providers/schemas.py` - TranslationRequest/Response models

**External:**
- `httpx` - HTTP client (preferred for async/sync support)
- `structlog` or standard `logging` - Structured logging

### HTTP Client Pattern

Use `httpx` for OpenAI API calls:

```python
import httpx

class OpenAITranslationProvider(TranslationProvider):
    def __init__(self, api_key: str, model: str = "gpt-4o-mini", timeout: int = 60, base_url: str = "https://api.openai.com/v1"):
        self._api_key = api_key
        self._model = model
        self._base_url = base_url.rstrip("/")
        self._timeout = timeout
        self._client = httpx.Client(
            timeout=timeout,
            headers={
                "Authorization": f"Bearer {api_key}",
                "Content-Type": "application/json"
            }
        )
    
    def _make_api_request(self, text: str, system_prompt: str) -> str:
        response = self._client.post(
            f"{self._base_url}/v1/chat/completions",
            json={
                "model": self._model,
                "messages": [
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": text}
                ],
                "temperature": 0.3,
                "max_tokens": 4096
            }
        )
        # ... error handling based on status code
        return response.json()["choices"][0]["message"]["content"]
```

### Security Considerations

**API Key Management:**
- API key stored in environment variable (never in code)
- Key validated at initialization
- Never log the API key (only last 4 characters if needed for debugging)

**Data Privacy:**
- Never log document content (NFR11)
- Only log metadata: text length, languages, model, timestamps
- OpenAI may retain data per their privacy policy (different from Ollama's local processing)

### Pro Feature Integration

Per PRD FR26: "Pro users can access LLM translation modes"

This provider will be used when:
- User tier is "pro"
- User selects "LLM" mode
- User selects "OpenAI" as LLM provider

The tier check happens in the translation service/router, not in the provider itself.

### Rate Limiting Handling

OpenAI returns rate limit info in response headers:
- `x-ratelimit-limit-requests`
- `x-ratelimit-remaining-requests`
- `x-ratelimit-reset-requests`

Extract `retry_after` from error response or use exponential backoff.

### References

- [Source: _bmad-output/planning-artifacts/architecture.md#Error Handling]
- [Source: _bmad-output/planning-artifacts/architecture.md#API Response Formats]
- [Source: _bmad-output/planning-artifacts/epics.md#Story 2.5]
- [Source: _bmad-output/planning-artifacts/prd.md#FR7 LLM providers (Ollama, OpenAI)]
- [Source: _bmad-output/planning-artifacts/prd.md#NFR12 Zero HTTP 500 errors]
- [Source: _bmad-output/implementation-artifacts/2-4-provider-ollama-llm-local.md]
- [Source: services/providers/ollama_provider.py - Implementation pattern]
- [Source: https://platform.openai.com/docs/api-reference/chat - OpenAI API docs]
- [Source: https://platform.openai.com/docs/guides/error-codes - OpenAI Error Codes]

## Dev Agent Record

### Agent Model Used

Claude (GLM-5) via opencode

### Debug Log References

- Fixed test mocking issues for registry integration tests
- Resolved ProvidersConfig import path in tests

### Completion Notes List

- ✅ Implemented `OpenAITranslationProvider` class with full OpenAI Chat Completions API integration
- ✅ All 6 error codes implemented with French messages: OPENAI_RATE_LIMITED, OPENAI_INVALID_KEY, OPENAI_QUOTA_EXCEEDED, OPENAI_TIMEOUT, OPENAI_SERVICE_ERROR, OPENAI_CONTEXT_TOO_LONG
- ✅ Retry logic with exponential backoff for transient errors (rate limits, timeouts, service errors)
- ✅ Health check with 60s TTL caching and model availability verification
- ✅ Registry integration with auto-registration when OPENAI_ENABLED=true
- ✅ Custom system prompt injection via request.metadata["custom_prompt"]
- ✅ Language name mapping for better LLM understanding (same as Ollama)
- ✅ 44 unit tests created and all passing
- ✅ Configuration updated in config.py with OPENAI_TIMEOUT, OPENAI_MAX_RETRIES, OPENAI_RETRY_DELAY, OPENAI_BASE_URL, OPENAI_HEALTH_CHECK_TIMEOUT
- ✅ Auto-registration added to __init__.py
- ✅ All acceptance criteria (AC1-AC8) satisfied

### Code Review Fixes (2026-02-21)

- ✅ [HIGH] Added model info to `health_check()` return (`model`, `model_available` fields per Task 3.4)
- ✅ [MEDIUM] Added configurable `health_check_timeout` parameter (default 5s, via OPENAI_HEALTH_CHECK_TIMEOUT)
- ✅ [MEDIUM] Added `reset_openai_provider()` function to reset singleton when config changes
- ✅ [MEDIUM] Added API key validation (empty key raises ValueError)
- ✅ [MEDIUM] Added 11 new tests covering: empty API key, text too long preemptive check, malformed API responses (empty choices, missing content), health check model info, reset function

### File List

**Files Created:**
- `services/providers/openai_provider.py` - Main OpenAI provider implementation (660 lines)
- `tests/test_providers/test_openai_provider.py` - 44 unit tests covering all functionality

**Files Modified:**
- `services/providers/__init__.py` - Added OpenAI auto-registration
- `services/providers/config.py` - Added OPENAI_TIMEOUT, OPENAI_MAX_RETRIES, OPENAI_RETRY_DELAY, OPENAI_BASE_URL, OPENAI_HEALTH_CHECK_TIMEOUT
- `services/providers/README.md` - OpenAI section (Task 7)
- `.env.example` - Added OPENAI_HEALTH_CHECK_TIMEOUT and OpenAI config options

### Change Log

- 2026-02-21: [AI Code Review 2-5/2-6] Fixes: defensive JSON for 429/400, tokens_used in success log, ProviderSettings.openai base_url in config, File List README
- 2026-02-21: Code review fixes applied - Added model info to health_check, configurable health check timeout, reset function for singleton, API key validation, 11 new tests
- 2026-02-21: Story 2.5 implementation complete - OpenAI provider with cloud LLM translation, custom prompts, comprehensive error handling with French messages, retry logic, health checks, and 44 passing tests
