# Phase 7: Redo and Optimize LLM Integration - Research

**Researched:** 2026-06-02
**Domain:** AI provider abstraction layer, Celery retry, structured output, DB-driven config
**Confidence:** HIGH

---

<user_constraints>
## User Constraints (from CONTEXT.md)

### Locked Decisions

- **D-01:** All OpenAI-compatible providers (OpenAI, Ollama, LMStudio, Groq, Gemini-compat, Mistral-compat, xAI/Grok, DeepSeek, OpenRouter) must pass `response_format={"type": "json_object"}` in every `classify()` and `suggest_topics()` call.
- **D-02:** Keep `parse_classification()` and `parse_suggestions()` in `ai/utils.py` as a last-resort fallback only — they are called when a provider does not support `response_format` (e.g., some local Ollama models that ignore the parameter).
- **D-04:** All AI provider settings (API keys, base URLs, model names, active provider) are stored in a new `system_settings` DB table. Env vars serve as startup defaults that populate the table on first boot, but the DB record is authoritative at runtime.
- **D-05:** API keys stored in `system_settings` must be encrypted at rest — use the same HKDF/AES-GCM pattern already established for cloud credentials in `backend/storage/cloud_utils.py`.
- **D-06:** `get_provider()` accepts typed Pydantic `ProviderConfig` models (not raw dicts). The classifier assembles a `ProviderConfig` from the DB record.
- **D-08:** Admin panel gains an "AI Providers" configuration section where admins can set the active provider, configure API keys/base URLs/models, and test connectivity via the existing `health_check()` method.
- **D-09:** The `extract_and_classify` Celery task uses Celery's built-in retry mechanism (`self.retry(exc=…, countdown=…, max_retries=3)`) with exponential backoff: first retry at 30 s, second at 90 s, third at 270 s.
- **D-10:** After all retries are exhausted the document status remains `classification_failed`. No silent failure.
- **D-11:** The document card shows a "Classification failed" badge when `status == "classification_failed"`. A "Re-analyze" button calls `POST /api/documents/{id}/classify` to re-queue the Celery task.
- **D-12:** `MAX_AI_CHARS` is replaced by a per-provider `context_chars` value declared in each provider's config (or `ProviderConfig` default). The global constant is removed.
- **D-13:** Smart truncation strategy: take the first 60% of the per-provider limit + the last 40%.
- **D-14:** Add `extra_hosts: ["host.docker.internal:host-gateway"]` to the `backend` and `celery-worker` services in `docker-compose.yml`.
- **D-15:** LMStudio base URL and model are configurable via the admin AI Providers panel.
- **D-16:** Introduce a `GenericOpenAIProvider(base_url, api_key, model, context_chars)` class in `backend/ai/generic_openai_provider.py`. It subclasses `OpenAIProvider` and adds JSON-mode enforcement.
- **D-17:** Providers to add: Google Gemini (via OpenAI-compat endpoint), Mistral (via OpenAI-compat), Groq, xAI/Grok, DeepSeek, OpenRouter — all via `GenericOpenAIProvider`.
- **D-18:** Mistral uses the OpenAI-compat endpoint (not the native `mistralai` SDK).

### Claude's Discretion

- **D-03 (Anthropic output):** Researcher determines the most reliable pattern for structured JSON from Claude models consistent with D-01.
- **D-07 (Client lifecycle):** Researcher documents optimal client instantiation per provider (singleton, per-request, pooled) clearly enough that adding a new provider requires no re-research.

### Deferred Ideas (OUT OF SCOPE)

- Chunk + merge multi-call classification (too expensive per-API call in this phase)
- Native `mistralai` SDK (OpenAI-compat is sufficient)
- Token counting with `tiktoken` or provider tokenizers
- Streaming classification

</user_constraints>

---

## Summary

Phase 7 is a focused refactor of an already-functional AI layer. The goal is reliability and extensibility: move provider settings from env vars into a DB table (`system_settings`), introduce a `GenericOpenAIProvider` that covers all OpenAI-compatible APIs in one class, enforce JSON-mode structured output across all providers, add Celery retry with exponential backoff for classification failures, and surface a "Re-analyze" button in the UI when classification fails.

The research resolves the two open design questions: (1) Anthropic now supports native `output_config.format.type="json_schema"` on all current Claude models with constrained decoding — no `tool_use` workaround is needed; (2) the optimal client lifecycle pattern is a per-provider module-level singleton that is constructed once at provider instantiation and stored as `self._client`, not recreated per API call.

A critical codebase finding: `extra_hosts: ["host.docker.internal:host-gateway"]` is **already present** on both `backend` and `celery-worker` in `docker-compose.yml` — D-14 is already done. Similarly, `POST /api/documents/{id}/classify` already exists in `api/documents.py` and `classifyDocument()` is already in the frontend API client. The phase needs to change what happens inside that endpoint (re-queue Celery instead of running synchronously) and update the frontend badge logic, not add new routing from scratch.

**Primary recommendation:** Implement in five waves: (1) Alembic migration + `system_settings` model + encryption helpers, (2) `ProviderConfig` Pydantic models + `GenericOpenAIProvider` + refactored provider constructors, (3) Anthropic `output_config` structured output + classifier refactor with smart truncation, (4) Celery retry harness + re-classify endpoint to re-queue, (5) Admin AI Config panel UI + DocumentCard badge + tests.

---

## Architectural Responsibility Map

| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| system_settings read/write | Database (PostgreSQL ORM) | API / Backend | Settings are global admin config, live in DB |
| API key encryption/decryption | API / Backend (services layer) | — | Crypto must never happen in the DB or frontend |
| Provider factory / get_provider() | API / Backend (`ai/__init__.py`) | — | Instantiation is a backend concern |
| JSON structured output enforcement | API / Backend (each provider class) | — | Each provider's `classify()` sets response_format |
| Context window truncation | API / Backend (each provider class) | — | Done before calling the provider API |
| Celery retry logic | Celery worker (`tasks/document_tasks.py`) | — | Task retry is a worker-level concern |
| Re-classify trigger | API / Backend endpoint | Frontend (button) | Backend re-queues; frontend emits the call |
| Admin AI config UI | Frontend (AdminAiConfigTab.vue) | Admin API endpoint | UI displays/saves; backend enforces |
| Classification failed badge | Frontend (DocumentCard.vue) | — | UI reads `doc.status == "classification_failed"` |

---

## Standard Stack

### Core (no new packages — all already in requirements.txt)

| Library | Version (pinned min) | Purpose | Why Standard |
|---------|---------------------|---------|--------------|
| `openai` | `>=1.30` (latest: 2.40.0) | AsyncOpenAI for OpenAI + all compat providers | Official SDK; manages httpx connection pool |
| `anthropic` | `>=0.26` (latest: 0.105.2) | AsyncAnthropic for native Claude output | Official SDK; supports `output_config.format` |
| `celery[redis]` | `>=5.5.0` (latest: 5.6.3) | Task retry with exponential backoff | Already used for document processing |
| `cryptography` | `>=41.0.0` | HKDF/AES-GCM for `system_settings` API key encryption | Same pattern as cloud credentials |
| `pydantic` | `>=2.0` | `ProviderConfig` typed models | Already used everywhere |
| `sqlalchemy[asyncio]` | `>=2.0.49` | `SystemSettings` ORM model | Already used everywhere |
| `httpx` | `>=0.27` (latest: 0.28.1) | Transport layer for all async HTTP clients | Used internally by both openai and anthropic SDKs |

**No new packages need to be added to `requirements.txt`** for this phase. All required libraries are already present.

### Alternatives Considered

| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| `openai` SDK for compat providers | `httpx` directly | openai SDK manages connection pooling, retries, and auth headers automatically — less code |
| `output_config.format` for Anthropic | `tool_use` JSON schema | `output_config` is simpler, no tool_call parsing needed, and uses constrained decoding |
| Per-provider `context_chars` int | `tiktoken` token counts | Char approximation is 4x cheaper in CPU and requires no extra dependency; deferred in CONTEXT.md |

---

## Package Legitimacy Audit

No new packages are introduced in this phase. All libraries (`openai`, `anthropic`, `celery`, `cryptography`, `pydantic`, `sqlalchemy`, `httpx`) are long-established, present in `requirements.txt`, and well-known to the ecosystem.

| Package | Registry | Age | Status |
|---------|----------|-----|--------|
| `openai` | PyPI | ~5 yrs | Established — official OpenAI Python SDK |
| `anthropic` | PyPI | ~3 yrs | Established — official Anthropic Python SDK |
| `celery` | PyPI | ~15 yrs | Established |
| `cryptography` | PyPI | ~10 yrs | Established |

**Packages removed due to slopcheck verdict:** none — no new packages.

---

## Architecture Patterns

### System Architecture Diagram

```
[Admin Browser]
    │ PUT /api/admin/ai-config
    ▼
[Admin API (admin.py)]
    │ HKDF-encrypt(api_key)  write to system_settings table
    ▼
[PostgreSQL: system_settings table]
    │ load_provider_config(session) reads on each classify call
    ▼
[classifier.py — classify_document()]
    │ builds ProviderConfig  calls get_provider(config)
    ▼
[ai/__init__.py — get_provider(config: ProviderConfig)]
    │ registry lookup  construct AnthropicProvider | GenericOpenAIProvider
    ▼
[Provider instance (singleton _client)]
    │ classify() / suggest_topics()  send request to LLM API
    ▼
[External LLM API]
    │ response JSON (or fallback parse_classification())
    ▼
[classifier.py] — persist topics to DB
    ▲
[Celery task: extract_and_classify (bind=True)]
    │ on exception: self.retry(countdown=30|90|270, max_retries=3)
    │ on exhaustion: doc.status = "classification_failed"
    ▼
[Document: status field]
    │ polled by frontend
    ▼
[DocumentCard.vue]
    └── classification_failed badge + Re-analyze button
              │ POST /api/documents/{id}/classify
              ▼
        [documents.py — re-queues Celery task]
```

### Recommended Project Structure (additions only)

```
backend/
├── ai/
│   ├── base.py                        # AIProvider ABC (no change)
│   ├── utils.py                       # parse_classification/parse_suggestions (no change)
│   ├── __init__.py                    # get_provider(config: ProviderConfig) — refactored
│   ├── provider_config.py             # NEW: ProviderConfig Pydantic model + presets
│   ├── generic_openai_provider.py     # NEW: GenericOpenAIProvider(OpenAIProvider)
│   ├── openai_provider.py             # ADD context_chars, singleton _client, json_mode
│   ├── anthropic_provider.py          # ADD output_config structured output, singleton _client
│   ├── ollama_provider.py             # UPDATE: pass context_chars
│   └── lmstudio_provider.py           # UPDATE: pass context_chars
├── db/
│   └── models.py                      # ADD SystemSettings ORM model
├── alembic/versions/
│   └── 0005_system_settings.py        # NEW: migration for system_settings table
├── services/
│   └── ai_config.py                   # NEW: load_provider_config(session) helper
└── tasks/
    └── document_tasks.py              # REFACTOR: bind=True, self.retry() exponential backoff

frontend/src/
├── components/
│   ├── admin/
│   │   └── AdminAiConfigTab.vue       # REPLACE: global provider config (not per-user)
│   └── documents/
│       └── DocumentCard.vue           # ADD: classification_failed badge + Re-analyze button
└── api/
    └── client.js                      # ADD: reclassifyDocument(), getAiConfig(), saveAiConfig()
```

---

## Don't Hand-Roll

| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| JSON schema enforcement on LLM output | Custom regex/JSON repair | `response_format={"type":"json_object"}` (OpenAI compat) or `output_config.format` (Anthropic) | SDK-level constraint; cannot produce invalid JSON with constrained decoding |
| Exponential backoff with jitter | Manual sleep loop | `self.retry(countdown=N, max_retries=3)` in Celery | Celery handles scheduling, worker redelivery, and failure state atomically |
| Per-provider connection pools | Custom httpx session management | Module-level `AsyncOpenAI` / `AsyncAnthropic` singletons | Both SDKs manage httpx connection pools internally; re-instantiation destroys pool reuse |
| HKDF key derivation | AES with hardcoded key | `_derive_fernet_key(master_key, provider_id)` from `storage/cloud_utils.py` | Pattern already battle-tested in Phase 5; reuse verbatim |
| Provider capability detection | Runtime feature probe | Static `supports_json_mode: bool` flag per preset | Avoids extra API calls; Ollama/LMStudio's fallback is already handled in D-02 |

**Key insight:** The entire OpenAI-compat ecosystem (Groq, xAI, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat) is handled by a single `GenericOpenAIProvider` with different `base_url` defaults. The JSON-mode parameter travels through unchanged. Do not implement separate classes for each.

---

## D-03 Resolved: Anthropic Structured Output Strategy

**Decision:** Use `output_config={"format": {"type": "json_schema", "schema": {...}}}` in `messages.create()`.

**Rationale:**
- The Anthropic Python SDK (v0.95+) supports `output_config.format.type = "json_schema"` with constrained decoding — the model is physically prevented from producing invalid JSON matching the schema. [VERIFIED: platform.claude.com/docs/en/build-with-claude/structured-outputs]
- Supported on all current Claude models: Claude Haiku 4.5, Sonnet 4.5, Sonnet 4.6, Opus 4.5, Opus 4.6, Opus 4.7, Opus 4.8. [VERIFIED: platform.claude.com/docs/en/build-with-claude/structured-outputs]
- No beta headers are required as of SDK ≥0.95 (the old `structured-outputs-2025-11-13` header is still accepted for backward compat but no longer needed). [VERIFIED: platform.claude.com/docs/en/build-with-claude/structured-outputs]
- The `tool_use` approach requires an extra layer of parsing (`content[0].input` instead of `content[0].text`) and forces Claude to "call a function" rather than respond — semantically wrong for classification. `output_config` is the correct API.
- The higher-level `messages.parse()` with a Pydantic model is convenient but requires importing Pydantic into the provider — adds coupling. Use the raw `output_config` dict instead, read the result from `response.content[0].text`, and let the existing `parse_classification()` in `ai/utils.py` handle the fallback path.

**Schema to use for classification:**
```python
_CLASSIFICATION_SCHEMA = {
    "type": "object",
    "properties": {
        "assigned_topics": {"type": "array", "items": {"type": "string"}},
        "new_topic_suggestions": {"type": "array", "items": {"type": "string"}},
        "reasoning": {"type": "string"},
    },
    "required": ["assigned_topics", "new_topic_suggestions"],
    "additionalProperties": False,
}

_SUGGESTIONS_SCHEMA = {
    "type": "object",
    "properties": {
        "suggested_topics": {"type": "array", "items": {"type": "string"}},
    },
    "required": ["suggested_topics"],
    "additionalProperties": False,
}
```

**Important limitation:** `output_config` fails with stop_reason `"refusal"` or `"max_tokens"` — the fallback `parse_classification(raw)` must still be called when the response does not terminate with `"end_turn"`.

---

## D-07 Resolved: Client Lifecycle per Provider

**Decision: Per-provider instance-level singleton** — create the client once in `__init__`, store as `self._client`, reuse across all calls.

### Evidence

- `AsyncOpenAI` wraps an `httpx.AsyncClient` internally, which maintains a connection pool. Calling `AsyncOpenAI(...)` in `_client()` on every API request (current pattern) destroys the pool and forces a new TLS handshake per request. This is explicitly called out as the cause of poor concurrency in the openai-python issue tracker. [CITED: github.com/openai/openai-python/issues/1725]
- The official recommendation is to create one `AsyncOpenAI` instance per process startup and reuse it. [CITED: developers.openai.com/api/reference/python]
- `AsyncAnthropic` wraps `httpx.AsyncClient` with identical semantics. The SDK documents that both sync and async clients share the same pool-management model. [CITED: deepwiki.com/anthropics/anthropic-sdk-python/4.2-synchronous-and-asynchronous-clients]
- `httpx.AsyncClient` (used for any future raw-HTTP provider) must NOT be closed between requests. It should be created once and optionally closed in an `asyncio` cleanup hook. [CITED: www.python-httpx.org/async/]

**Breaking change warning:** `openai` SDK 2.34.0+ raises `OpenAIError: Missing credentials` when `api_key=""`. The current `OllamaProvider` and `LMStudioProvider` pass `api_key="ollama"` and `api_key="lm-studio"` respectively — these are non-empty strings and are safe. `GenericOpenAIProvider` must use a non-empty placeholder (e.g., `"not-needed"`) when the user leaves the API key blank. [CITED: github.com/openai/openai-python/issues/3224]

### Pattern per provider

```python
# Pattern: store client as instance attribute, never recreate per call

class OpenAIProvider(AIProvider):
    def __init__(self, api_key: str, model: str, base_url: str | None, context_chars: int):
        self._api_key = api_key or "not-needed"   # SDK 2.34+ rejects empty string
        self._model = model
        self._base_url = base_url
        self._context_chars = context_chars
        # Singleton: created once, reused for all calls on this instance
        self._client = AsyncOpenAI(api_key=self._api_key, base_url=self._base_url)

class AnthropicProvider(AIProvider):
    def __init__(self, api_key: str, model: str, context_chars: int):
        self._api_key = api_key
        self._model = model
        self._context_chars = context_chars
        # Singleton: AsyncAnthropic manages its own httpx.AsyncClient internally
        self._client = AsyncAnthropic(api_key=self._api_key)
```

**Why NOT FastAPI lifespan singleton:** Provider instances are constructed from `system_settings` DB rows which can change at runtime (admin reconfigures). A module-level singleton would require re-initialization on config change. Instance-level singleton (store in `self._client`) is the correct level — each new `ProviderConfig` → new provider instance → new client. Old instance is GC'd when no longer referenced.

**Async safety:** `AsyncOpenAI` and `AsyncAnthropic` use asyncio-native httpx clients. They are safe to call concurrently from multiple coroutines on the same event loop (which is the Celery worker context via `asyncio.run()`). They are NOT thread-safe across separate OS threads. Since each Celery task call creates its own `asyncio.run()` event loop, each task call creates its own provider instance — no cross-task sharing occurs.

---

## Architecture Patterns

### Pattern 1: system_settings Table Design

**Recommendation:** One row per provider (not key-value store).

**Columns:**
```sql
CREATE TABLE system_settings (
    id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    provider_id VARCHAR NOT NULL UNIQUE,   -- "openai", "anthropic", "groq", etc.
    api_key_enc TEXT,                      -- Fernet-encrypted, NULL for local providers
    base_url    TEXT,                      -- NULL means use provider SDK default
    model_name  TEXT NOT NULL DEFAULT '',  -- e.g. "gpt-4o", "claude-sonnet-4-6"
    context_chars INTEGER NOT NULL DEFAULT 8000,
    is_active   BOOLEAN NOT NULL DEFAULT FALSE,  -- only one row has is_active=TRUE at a time
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
```

**Why one-row-per-provider over key-value:**
- Each provider has a fixed set of fields (api_key, base_url, model, context_chars) — a typed row is far better than `key TEXT, value TEXT` pairs that require manual assembly.
- Enforces `UNIQUE(provider_id)` — no duplicate providers.
- A single `WHERE is_active = TRUE` query loads the active provider config.
- The admin UI maps naturally to a table of provider rows.

**Encryption key derivation for system_settings:** Unlike cloud credentials (which use `user_id` as the HKDF salt), `system_settings` API keys are global. Use `provider_id` as the salt:

```python
# In services/ai_config.py — mirrors cloud_utils.py pattern exactly
def _derive_ai_settings_key(master_key: bytes, provider_id: str) -> Fernet:
    hkdf = HKDF(
        algorithm=hashes.SHA256(),
        length=32,
        salt=provider_id.encode("utf-8"),
        info=b"ai-provider-settings",  # different info= for domain separation
    )
    raw_key = hkdf.derive(master_key)
    return Fernet(base64.urlsafe_b64encode(raw_key))
```

**Master key:** Reuse `settings.cloud_creds_key` (already in env/config.py) — the `info=` parameter (`b"ai-provider-settings"` vs `b"cloud-credentials"`) provides domain separation so the same master key cannot produce the same derived key for both uses.

### Pattern 2: ProviderConfig Pydantic Model

```python
# backend/ai/provider_config.py
from pydantic import BaseModel

class ProviderConfig(BaseModel):
    provider_id: str          # "openai", "anthropic", "groq", etc.
    api_key: str = ""         # decrypted at load time; empty for local providers
    base_url: str | None = None
    model: str = ""
    context_chars: int = 8000

# Named preset constructors (factories — do NOT instantiate providers here)
PROVIDER_DEFAULTS: dict[str, dict] = {
    "openai":     {"base_url": None,                    "model": "gpt-4o",              "context_chars": 120_000},
    "anthropic":  {"base_url": None,                    "model": "claude-sonnet-4-6",   "context_chars": 180_000},
    "gemini":     {"base_url": "https://generativelanguage.googleapis.com/v1beta/openai/", "model": "gemini-2.0-flash", "context_chars": 800_000},
    "groq":       {"base_url": "https://api.groq.com/openai/v1",         "model": "llama-3.3-70b-versatile", "context_chars": 128_000},
    "xai":        {"base_url": "https://api.x.ai/v1",                    "model": "grok-3-mini",  "context_chars": 128_000},
    "deepseek":   {"base_url": "https://api.deepseek.com",               "model": "deepseek-chat", "context_chars": 60_000},
    "openrouter": {"base_url": "https://openrouter.ai/api/v1",           "model": "anthropic/claude-3.5-sonnet", "context_chars": 180_000},
    "mistral":    {"base_url": "https://api.mistral.ai/v1",              "model": "mistral-large-latest", "context_chars": 128_000},
    "ollama":     {"base_url": "http://host.docker.internal:11434/v1",   "model": "llama3.2",     "context_chars": 8_000},
    "lmstudio":   {"base_url": "http://host.docker.internal:1234/v1",    "model": "gemma-4-e4b-it", "context_chars": 8_000},
}
```

**Note:** Context char values above are [ASSUMED] approximations based on known context window sizes of common models. The exact defaults are the admin's configuration responsibility — these are only startup defaults.

### Pattern 3: GenericOpenAIProvider

```python
# backend/ai/generic_openai_provider.py
from openai import AsyncOpenAI
from ai.openai_provider import OpenAIProvider

class GenericOpenAIProvider(OpenAIProvider):
    """OpenAI-compatible provider for any endpoint.

    Subclasses OpenAIProvider and enforces JSON mode on every call.
    Named presets (Groq, xAI, DeepSeek, OpenRouter, Gemini-compat, Mistral-compat,
    Ollama, LMStudio) are factory shortcuts in get_provider().
    """

    async def classify(self, document_text, existing_topics, system_prompt):
        topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)"
        truncated = self._truncate(document_text)
        user_msg = f"Existing topics: [{topics_str}]\n\nDocument text:\n{truncated}"
        response = await self._client.chat.completions.create(
            model=self._model,
            max_tokens=1024,
            response_format={"type": "json_object"},   # D-01 enforcement
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_msg},
            ],
        )
        raw = response.choices[0].message.content or ""
        return parse_classification(raw)   # parse_classification handles well-formed JSON too

    def _truncate(self, text: str) -> str:
        """D-13: first 60% + last 40% of context window."""
        limit = self._context_chars
        if len(text) <= limit:
            return text
        head = int(limit * 0.6)
        tail = limit - head
        return text[:head] + "\n[...truncated...]\n" + text[-tail:]
```

### Pattern 4: Celery Retry with Exponential Backoff

The existing `extract_and_classify` task uses `@celery_app.task(name=...)`. To enable `self.retry()`, the decorator must add `bind=True`.

**Important:** The task is a sync `def` that calls `asyncio.run(_run(...))`. The retry must happen at the sync task level, not inside `_run()`, because Celery's retry mechanism is synchronous (it raises `Retry` exception).

```python
# backend/tasks/document_tasks.py

@celery_app.task(
    name="tasks.document_tasks.extract_and_classify",
    bind=True,
    max_retries=3,
    default_retry_delay=30,
)
def extract_and_classify(self, document_id: str) -> dict:
    """Synchronous Celery entry-point — delegates to async _run via asyncio.run."""
    try:
        return asyncio.run(_run(document_id))
    except Exception as exc:
        result = getattr(exc, "result", None)
        # Only retry on classification failures, not on extract/storage failures.
        # _run() returns a dict for extract/storage errors (non-retryable).
        # It raises only for classification exceptions.
        retry_count = self.request.retries
        countdowns = [30, 90, 270]
        countdown = countdowns[retry_count] if retry_count < len(countdowns) else 270
        raise self.retry(exc=exc, countdown=countdown)
```

**Revised approach** (cleaner): Have `_run()` raise `ClassificationError` for classification failures and return a dict for non-retryable storage/extract failures. The Celery task catches `ClassificationError` and calls `self.retry()`. On exhaustion, `MaxRetriesExceededError` is caught and `doc.status = "classification_failed"` is written.

```python
# Cleaner separation:
@celery_app.task(
    name="tasks.document_tasks.extract_and_classify",
    bind=True,
    max_retries=3,
)
def extract_and_classify(self, document_id: str) -> dict:
    try:
        return asyncio.run(_run(document_id))
    except _ClassificationError as exc:
        retry_num = self.request.retries
        countdown = [30, 90, 270][min(retry_num, 2)]
        raise self.retry(exc=exc, countdown=countdown)
    except self.MaxRetriesExceededError:
        # All retries exhausted — write classification_failed status
        asyncio.run(_mark_classification_failed(document_id))
        return {"document_id": document_id, "status": "classification_failed"}
```

**Document status during retries:** Leave `doc.status = "processing"` during retries (D-10 only specifies the final state). This way the frontend shows "processing" (spinner) rather than "failed" prematurely. Only when all retries are exhausted does the status flip to `"classification_failed"`.

### Pattern 5: Re-classify Endpoint (D-11)

The existing `POST /api/documents/{id}/classify` in `api/documents.py` runs classification synchronously. It needs to be changed to re-queue the Celery task instead:

```python
@router.post("/{doc_id}/classify")
async def reclassify_document(
    doc_id: str,
    session: AsyncSession = Depends(get_db),
    current_user: User = Depends(get_regular_user),
):
    """Re-queue the extract_and_classify Celery task for a failed document."""
    doc = await _get_owned_doc(doc_id, current_user, session)  # raises 404 on wrong owner
    doc.status = "processing"
    await session.commit()
    extract_and_classify.delay(doc_id)
    return {"document_id": doc_id, "status": "processing"}
```

**Note:** The existing endpoint already correctly does ownership check (`doc.user_id != current_user.id`). The change is: replace the synchronous `await classifier.classify_document(...)` with `.delay()`.

### Anti-Patterns to Avoid

- **Re-creating the AI client per request:** The current `_client(self)` method in `OpenAIProvider` returns a new `AsyncOpenAI(...)` on every call. This is the pattern to remove — it prevents connection pool reuse.
- **Global MAX_AI_CHARS constant:** `MAX_AI_CHARS = 8_000` is defined in both `openai_provider.py` and `anthropic_provider.py` and also in `classifier.py`. All three must be removed and replaced by per-provider `context_chars` from the config.
- **Inline `_settings` dict in classifier.py:** `_settings = {"active_provider": _ai_provider, "providers": {...}}` must be replaced by `load_provider_config(session)` returning a `ProviderConfig`.
- **`if/elif` chain in `get_provider()`:** Replace with a registry dict for O(1) lookup.
- **Mutating shared provider state:** If the admin changes config, `get_provider()` must return a fresh instance — never mutate `self._api_key` on an existing instance.

---

## JSON Mode Compatibility Matrix

This is critical for the planner to decide which providers need the `parse_classification()` fallback.

| Provider | JSON Mode Support | Notes |
|----------|------------------|-------|
| OpenAI (GPT-4o, etc.) | Full — `response_format={"type":"json_object"}` | [VERIFIED: developers.openai.com/api/reference/python] |
| Anthropic (Claude) | Full — `output_config.format.type="json_schema"` | [VERIFIED: platform.claude.com/docs/en/build-with-claude/structured-outputs] |
| Groq | Full — `response_format={"type":"json_object"}` | Requires schema in system prompt too [CITED: console.groq.com/docs/structured-outputs] |
| Mistral (compat) | Full — `response_format={"type":"json_object"}` | Must instruct model in system prompt as well [CITED: docs.mistral.ai/capabilities/structured_output/json_mode] |
| Gemini (via OpenAI compat) | Partial — supports `response_format=CalendarEvent` (Pydantic/schema), NOT `json_object` string | [CITED: ai.google.dev/gemini-api/docs/openai] — see note |
| xAI/Grok | [ASSUMED] — likely `json_object` given OpenAI-compat claim | Verify against xAI docs before implementing |
| DeepSeek | [ASSUMED] — OpenAI-compat; DeepSeek-V3 supports JSON mode | Verify against DeepSeek docs |
| OpenRouter | Full — passes `json_object` through to underlying model | [CITED: openrouter.ai/docs/guides/features/structured-outputs] — quality varies by underlying model |
| Ollama | Partial — parameter accepted but some models ignore it | [ASSUMED based on known behavior] — fallback parse_classification() is D-02 |
| LMStudio | Partial — same as Ollama (OpenAI compat, model-dependent) | [ASSUMED] — fallback parse_classification() is D-02 |

**Gemini note:** The OpenAI-compat endpoint does NOT support `response_format={"type": "json_object"}` (string mode). It supports the structured Pydantic/schema form via `response_format=CalendarEvent` (using `client.beta.chat.completions.parse()`). For `GenericOpenAIProvider` which uses `completions.create()` with `response_format={"type":"json_object"}`, Gemini will likely ignore the parameter and return free-form text. [CITED: ai.google.dev/gemini-api/docs/openai]. **Implication for planning:** `GenericOpenAIProvider` should have a `supports_json_mode: bool = True` class-level flag, and the Gemini preset should set it to `False` to fall back to `parse_classification()`.

---

## Common Pitfalls

### Pitfall 1: OpenAI SDK 2.x Empty API Key Rejection
**What goes wrong:** `AsyncOpenAI(api_key="")` raises `OpenAIError: Missing credentials` in SDK 2.34.0+. Affects Ollama, LMStudio, and any GenericOpenAIProvider configured without a key.
**Why it happens:** SDK 2.34.0 added credential validation. Empty string is falsy.
**How to avoid:** Always pass a non-empty placeholder: `api_key=api_key or "not-needed"`.
**Warning signs:** `OpenAIError: Missing credentials` during provider construction.

### Pitfall 2: HKDF AlreadyFinalized if Instance is Reused
**What goes wrong:** Calling `hkdf.derive()` twice on the same HKDF object raises `AlreadyFinalized`.
**Why it happens:** The `cryptography` library marks HKDF instances as consumed after one derivation.
**How to avoid:** Create a fresh `HKDF(...)` instance on every `_derive_ai_settings_key()` call. Explicitly documented in `cloud_utils.py` — replicate the comment.
**Warning signs:** `cryptography.exceptions.AlreadyFinalized` in encryption tests.

### Pitfall 3: Celery `bind=True` + `asyncio.run()` Retry Scope
**What goes wrong:** `self.retry()` must be called from the sync task function, not from inside `asyncio.run(_run(...))`. If you raise `self.retry()` inside `_run()`, it escapes the event loop cleanup and may corrupt async state.
**Why it happens:** `asyncio.run()` runs a full event loop; raising a Celery `Retry` exception mid-loop is not supported.
**How to avoid:** `_run()` raises a custom `_ClassificationError`; the outer sync `extract_and_classify()` catches it and calls `self.retry()`.
**Warning signs:** `RuntimeError: Event loop is closed` after retry.

### Pitfall 4: is_active Dual-Write Race Condition
**What goes wrong:** Two concurrent admin requests both set `is_active=True` on different providers, resulting in two active providers.
**Why it happens:** Read-check then write is a TOCTOU pattern.
**How to avoid:** Use a single `UPDATE system_settings SET is_active = (provider_id = $target)` — updates all rows atomically. No Python-level read needed.

### Pitfall 5: Anthropic output_config Schema Complexity Limit
**What goes wrong:** Complex classification schemas (many optional properties, union types) may hit Anthropic's grammar compilation limits: 24 optional parameters, 16 union-type parameters, 180s timeout.
**Why it happens:** Constrained decoding compiles the schema to a grammar; complex schemas take longer.
**How to avoid:** Keep the schema simple — `assigned_topics: array[string]`, `new_topic_suggestions: array[string]`, `reasoning: string`. All required. No unions. This is already what `parse_classification()` expects.

### Pitfall 6: AdminAiConfigTab.vue Is Already Per-User (Wrong Scope)
**What goes wrong:** The existing `AdminAiConfigTab.vue` shows a per-user AI provider table (calling `adminListUsers()` and `adminUpdateAiConfig(userId, ...)`). Phase 7's admin AI Providers section is about *global* system-level provider settings, not per-user overrides.
**Why it happens:** The existing component was built for ADMIN-05 (per-user assignment). Phase 7 adds a different concept: global `system_settings`.
**How to avoid:** Add a new "Providers" sub-section in the admin AI Config tab for system-level settings. Keep the per-user assignment table. Do not confuse the two.

### Pitfall 7: MAX_AI_CHARS Defined in Three Places
**What goes wrong:** `MAX_AI_CHARS = 8_000` is defined in `openai_provider.py`, `anthropic_provider.py`, and `classifier.py`. Removing it from one but not the others leads to inconsistent truncation.
**How to avoid:** A single grep-and-delete step. The planner must include this as an explicit task action.

---

## Code Examples

### Anthropic output_config Usage

```python
# Source: platform.claude.com/docs/en/build-with-claude/structured-outputs
import anthropic

_CLASSIFICATION_SCHEMA = {
    "type": "object",
    "properties": {
        "assigned_topics": {"type": "array", "items": {"type": "string"}},
        "new_topic_suggestions": {"type": "array", "items": {"type": "string"}},
        "reasoning": {"type": "string"},
    },
    "required": ["assigned_topics", "new_topic_suggestions"],
    "additionalProperties": False,
}

class AnthropicProvider(AIProvider):
    def __init__(self, api_key: str, model: str, context_chars: int):
        self._model = model
        self._context_chars = context_chars
        self._client = anthropic.AsyncAnthropic(api_key=api_key)  # singleton

    async def classify(self, document_text, existing_topics, system_prompt):
        truncated = self._truncate(document_text)
        topics_str = ", ".join(existing_topics) if existing_topics else "(none yet)"
        user_msg = f"Existing topics: [{topics_str}]\n\nDocument text:\n{truncated}"
        response = await self._client.messages.create(
            model=self._model,
            max_tokens=1024,
            system=system_prompt,
            messages=[{"role": "user", "content": user_msg}],
            output_config={
                "format": {"type": "json_schema", "schema": _CLASSIFICATION_SCHEMA}
            },
        )
        # Fallback: if stop_reason is not "end_turn", response.content[0].text may be
        # empty or partial — parse_classification handles gracefully.
        raw = response.content[0].text if response.content else ""
        return parse_classification(raw)
```

### Celery Exponential Backoff

```python
# Source: docs.celeryq.dev/en/stable/userguide/tasks.html (bind + manual countdown)

class _ClassificationError(Exception):
    """Raised by _run() for retryable classification failures only."""

@celery_app.task(
    name="tasks.document_tasks.extract_and_classify",
    bind=True,
    max_retries=3,
)
def extract_and_classify(self, document_id: str) -> dict:
    """D-09: exponential backoff — 30s, 90s, 270s."""
    try:
        return asyncio.run(_run(document_id))
    except _ClassificationError as exc:
        countdowns = [30, 90, 270]
        countdown = countdowns[min(self.request.retries, 2)]
        raise self.retry(exc=exc, countdown=countdown)
```

### Smart Truncation (D-13)

```python
def _truncate(self, text: str) -> str:
    """First 60% + last 40% of context window (D-13)."""
    limit = self._context_chars
    if len(text) <= limit:
        return text
    head_len = int(limit * 0.6)
    tail_len = limit - head_len
    return text[:head_len] + "\n[...truncated...]\n" + text[-tail_len:]
```

### Alembic Migration 0005

```python
# backend/alembic/versions/0005_system_settings.py
"""Add system_settings table for AI provider configuration."""
from alembic import op
import sqlalchemy as sa

revision = "0005"
down_revision = "0004"

def upgrade():
    op.create_table(
        "system_settings",
        sa.Column("id", sa.dialects.postgresql.UUID(as_uuid=True), primary_key=True,
                  server_default=sa.text("gen_random_uuid()")),
        sa.Column("provider_id", sa.String, nullable=False),
        sa.Column("api_key_enc", sa.Text, nullable=True),
        sa.Column("base_url", sa.Text, nullable=True),
        sa.Column("model_name", sa.Text, nullable=False, server_default=""),
        sa.Column("context_chars", sa.Integer, nullable=False, server_default="8000"),
        sa.Column("is_active", sa.Boolean, nullable=False, server_default="false"),
        sa.Column("created_at", sa.TIMESTAMP(timezone=True), nullable=False,
                  server_default=sa.text("now()")),
        sa.Column("updated_at", sa.TIMESTAMP(timezone=True), nullable=False,
                  server_default=sa.text("now()")),
        sa.UniqueConstraint("provider_id", name="uq_system_settings_provider_id"),
    )

def downgrade():
    op.drop_table("system_settings")
```

### get_provider() Registry Pattern

```python
# backend/ai/__init__.py — O(1) lookup, no if/elif chain
from ai.provider_config import ProviderConfig
from ai.openai_provider import OpenAIProvider
from ai.anthropic_provider import AnthropicProvider
from ai.generic_openai_provider import GenericOpenAIProvider

_REGISTRY: dict[str, type] = {
    "openai":     OpenAIProvider,
    "anthropic":  AnthropicProvider,
    "gemini":     GenericOpenAIProvider,
    "groq":       GenericOpenAIProvider,
    "xai":        GenericOpenAIProvider,
    "deepseek":   GenericOpenAIProvider,
    "openrouter": GenericOpenAIProvider,
    "mistral":    GenericOpenAIProvider,
    "ollama":     GenericOpenAIProvider,
    "lmstudio":   GenericOpenAIProvider,
}

def get_provider(config: ProviderConfig) -> AIProvider:
    cls = _REGISTRY.get(config.provider_id)
    if cls is None:
        raise ValueError(f"Unknown AI provider: {config.provider_id!r}")
    return cls(
        api_key=config.api_key or "not-needed",
        model=config.model,
        base_url=config.base_url,
        context_chars=config.context_chars,
    )
```

Note: `AnthropicProvider` does not take `base_url` — its constructor signature will differ; handle in the factory.

---

## Runtime State Inventory

This section is not applicable — this is a greenfield addition and refactor phase, not a rename/migration phase. No stored data keys, live service configs, or OS-registered state change names.

The one potential runtime state concern is the existing `ai_provider` and `ai_model` columns on the `users` table. These per-user overrides remain in place (ADMIN-05 still uses them). The new `system_settings` table provides the *global system default* that is used when a user's per-user override is `NULL`. The per-user fields do not change.

---

## State of the Art

| Old Approach | Current Approach | When Changed | Impact |
|--------------|------------------|--------------|--------|
| Anthropic tool_use for structured output | `output_config.format.type="json_schema"` with constrained decoding | ~Nov 2025 (GA by June 2026) | No more JSON parsing fragility for Anthropic; model cannot produce invalid output |
| Celery `return {"status": "classification_failed"}` on exception | `self.retry(countdown=N, max_retries=3)` then status update on exhaustion | This phase | Classification failures now auto-retry with backoff |
| `_client()` method creating `AsyncOpenAI` per call | `self._client = AsyncOpenAI(...)` in `__init__` | This phase | Full httpx connection pool reuse |
| Flat `if/elif` provider registry | Dict-based `_REGISTRY` + `ProviderConfig` typed model | This phase | O(1) lookup, type safety, easy to add providers |
| `MAX_AI_CHARS = 8_000` global constant | Per-provider `context_chars` in `ProviderConfig` | This phase | Different providers support wildly different context sizes |
| Env var only AI config | DB-backed `system_settings` with admin UI | This phase | Runtime reconfiguration without restarting containers |

**Deprecated/outdated:**
- `MAX_AI_CHARS` constant in `openai_provider.py`, `anthropic_provider.py`, `classifier.py` — remove all three.
- The `_client()` method pattern in both providers — replace with `__init__` singleton.
- `get_provider(settings: dict)` raw dict interface — replace with `get_provider(config: ProviderConfig)`.
- Hardcoded `if active == "anthropic"` chain in `__init__.py` — replace with registry.

---

## Docker Compose D-14 Status

**Already done.** Both `backend` and `celery-worker` services already have:
```yaml
extra_hosts:
  - "host.docker.internal:host-gateway"
```
This was added in a prior phase. D-14 requires no docker-compose changes. The LMStudio fix is fully covered by making the base URL configurable via `system_settings` (D-15).

---

## Validation Architecture

`workflow.nyquist_validation = true` in `.planning/config.json` — this section is required.

### Test Framework

| Property | Value |
|----------|-------|
| Framework | pytest + pytest-asyncio |
| Config file | `pytest.ini` or `pyproject.toml` (existing) |
| Quick run command | `pytest backend/tests/test_classifier.py backend/tests/test_ai_providers.py -x` |
| Full suite command | `pytest backend/tests/ -v` |

### Phase Requirements → Test Map

| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|--------|----------|-----------|-------------------|-------------|
| D-01 | GenericOpenAIProvider passes `response_format={"type":"json_object"}` | unit (mock) | `pytest backend/tests/test_ai_providers.py::test_generic_openai_json_mode -x` | No — Wave 0 |
| D-03 | AnthropicProvider passes `output_config.format.type="json_schema"` | unit (mock) | `pytest backend/tests/test_ai_providers.py::test_anthropic_structured_output -x` | No — Wave 0 |
| D-04 | load_provider_config() reads from system_settings table | integration (real DB) | `INTEGRATION=1 pytest backend/tests/test_ai_config.py::test_load_provider_config -x` | No — Wave 0 |
| D-05 | system_settings API keys encrypt/decrypt correctly | unit | `pytest backend/tests/test_ai_config.py::test_api_key_encrypt_decrypt -x` | No — Wave 0 |
| D-05 | Admin GET /api/admin/ai-config never returns api_key_enc | integration | `pytest backend/tests/test_admin_ai_config.py::test_get_never_returns_key -x` | No — Wave 0 |
| D-06 | get_provider() accepts ProviderConfig, not raw dict | unit | `pytest backend/tests/test_ai_providers.py::test_get_provider_typed -x` | No — Wave 0 |
| D-07 | OpenAIProvider._client is not recreated on second call | unit | `pytest backend/tests/test_ai_providers.py::test_client_singleton -x` | No — Wave 0 |
| D-09 | extract_and_classify retries with 30s/90s/270s backoff | unit (mock) | `pytest backend/tests/test_document_tasks.py::test_retry_backoff -x` | No — Wave 0 |
| D-10 | After 3 retries doc.status = "classification_failed" | unit (mock) | `pytest backend/tests/test_document_tasks.py::test_exhaustion_sets_failed_status -x` | No — Wave 0 |
| D-12 | context_chars respected per provider | unit | `pytest backend/tests/test_ai_providers.py::test_context_chars_truncation -x` | No — Wave 0 |
| D-13 | Truncation is 60% head + 40% tail | unit | `pytest backend/tests/test_ai_providers.py::test_smart_truncation -x` | No — Wave 0 |
| D-11 | POST /api/documents/{id}/classify re-queues Celery | integration | `pytest backend/tests/test_documents.py::test_reclassify_requeues_celery -x` | No — Wave 0 |
| existing | parse_classification/parse_suggestions robustness | unit | `pytest backend/tests/test_classifier.py -x` | Yes |

### Sampling Rate
- **Per task commit:** `pytest backend/tests/test_classifier.py backend/tests/test_ai_providers.py -x`
- **Per wave merge:** `pytest backend/tests/ -v`
- **Phase gate:** Full suite green before `/gsd:verify-work`

### Wave 0 Gaps

- [ ] `backend/tests/test_ai_providers.py` — covers D-01, D-03, D-06, D-07, D-12, D-13
- [ ] `backend/tests/test_ai_config.py` — covers D-04, D-05 (encryption round-trip + DB read)
- [ ] `backend/tests/test_admin_ai_config.py` — covers admin endpoint security (never returns key)
- [ ] `backend/tests/test_document_tasks.py` (new tests in existing file) — covers D-09, D-10

*Note: `test_classifier.py` already exists and covers `parse_classification()` robustness — keep as-is.*

---

## Security Domain

### Applicable ASVS Categories

| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | No | — |
| V3 Session Management | No | — |
| V4 Access Control | Yes | `get_current_admin` on all `/api/admin/ai-config` endpoints |
| V5 Input Validation | Yes | Pydantic `ProviderConfig` validates all admin inputs |
| V6 Cryptography | Yes | HKDF/AES-GCM (same pattern as cloud credentials) |

### Known Threat Patterns

| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Admin endpoint returns api_key_enc | Information Disclosure | `_ai_config_to_dict()` whitelist — never include `api_key_enc` field |
| Prompt injection via base_url or model_name | Tampering | Pydantic validation; base_url validated against URL format; model_name is opaque string passed to provider |
| HKDF key reuse across domains | Elevation of Privilege | `info=b"ai-provider-settings"` ≠ `info=b"cloud-credentials"` — domain separation |
| Non-admin user reads system AI config | Unauthorized Access | Admin-only endpoints (`get_current_admin` dep) |
| Classification of attacker-controlled documents | Tampering | Document content is user's own; provider calls are server-side; no SSRF from provider base_url (it's admin-configured, not user-supplied) |

**Admin endpoint invariant:** `GET /api/admin/ai-config` and `PUT /api/admin/ai-config` must never return `api_key_enc`. Follow the `_user_to_dict()` whitelist pattern from `admin.py` — create `_ai_config_to_dict(row)` that includes `provider_id, base_url, model_name, context_chars, is_active, updated_at` but excludes `api_key_enc`. The API key field in the admin UI is write-only (masked input; never pre-filled from the server).

---

## Environment Availability

| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| PostgreSQL | system_settings table | Yes (Docker) | 17-alpine | — |
| Redis | Celery retry queue | Yes (Docker) | 7-alpine | — |
| Python openai SDK ≥1.30 | GenericOpenAIProvider | Yes (requirements.txt) | Latest: 2.40.0 | — |
| Python anthropic SDK ≥0.26 | AnthropicProvider | Yes (requirements.txt) | Latest: 0.105.2 | — |
| Python celery ≥5.5.0 | bind=True retry | Yes (requirements.txt) | Latest: 5.6.3 | — |
| docker-compose extra_hosts | LMStudio/Ollama host access | Already present in docker-compose.yml | — | — |

**Missing dependencies with no fallback:** None.

---

## Assumptions Log

| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | xAI/Grok API supports `response_format={"type":"json_object"}` | JSON Compatibility Matrix | Planner must add `supports_json_mode=False` preset for xAI if not supported; fallback to `parse_classification()` |
| A2 | DeepSeek supports `response_format={"type":"json_object"}` | JSON Compatibility Matrix | Same as A1 — fallback is already designed in D-02 |
| A3 | Context char defaults in PROVIDER_DEFAULTS table | Pattern 2 | Admin will reconfigure; only startup defaults; no functional risk |
| A4 | Ollama/LMStudio: `response_format` param is accepted but may be ignored by some models | JSON Compatibility Matrix | Already handled by D-02 fallback; no implementation risk |
| A5 | anthropic SDK version ≥0.95 is installed (output_config support added around v0.95) | D-03 section | If older version: `output_config` kwarg raises TypeError; bump requirement to `>=0.95.0` |

**Note on A5:** The `requirements.txt` pins `anthropic>=0.26`. The `output_config` parameter was introduced significantly after that. The planner must update the minimum pin to `anthropic>=0.95.0` (or preferably the current latest `0.105.2`).

---

## Open Questions (RESOLVED)

1. **xAI/Grok and DeepSeek JSON mode verification**
   - What we know: Both claim OpenAI-compat; industry pattern suggests they support `json_object`
   - What's unclear: Neither was directly verified against official docs in this research session
   - Recommendation: Planner should include a Wave 0 task to spot-check against official xAI and DeepSeek API docs, or treat both as [ASSUMED] and add `supports_json_mode` flag that defaults to `True` but can be overridden
   - RESOLVED: treated as [ASSUMED]; supports_json_mode flag is the mitigation — planner accepts the risk. xAI and DeepSeek are configured with supports_json_mode=True in Plan 02; if a future user reports JSON-shape failures, flipping the SUPPORTS_JSON_MODE entry to False routes that provider through parse_classification() (D-02 fallback path). No blocking action required for Phase 7 execution.

2. **anthropic SDK minimum version for output_config**
   - What we know: `output_config` is confirmed in current SDK (0.105.2) with no beta headers needed
   - What's unclear: The exact version when `output_config` was introduced (the old `output_format`/beta-header path still works)
   - Recommendation: Bump `requirements.txt` to `anthropic>=0.95.0` as part of Wave 1; if `output_format` param (old alias) is still working, the older SDK might work too — but it's safer to pin to a known good version
   - RESOLVED: treated as [ASSUMED]; supports_json_mode flag is the mitigation — planner accepts the risk. Plan 02 pins anthropic>=0.95.0 in requirements.txt; if a stricter floor is needed it can be raised in a follow-up phase. The fallback path (parse_classification) handles any output_config regression.

3. **Gemini-compat: use json_object or fall back?**
   - What we know: The OpenAI-compat endpoint does NOT document `json_object` string mode; it supports Pydantic schema via `client.beta.chat.completions.parse()`
   - What's unclear: Whether `json_object` is silently ignored or raises an error
   - Recommendation: Implement Gemini preset with `supports_json_mode=False`; `GenericOpenAIProvider.classify()` skips `response_format` for these presets and relies on `parse_classification()` fallback
   - RESOLVED: treated as [ASSUMED]; supports_json_mode flag is the mitigation — planner accepts the risk. Plan 02 SUPPORTS_JSON_MODE["gemini"] = False routes Gemini through the parse_classification() fallback path. Test test_gemini_fallback_to_parse_classification in Plan 02 Task 4 enforces this contract.

---

## Sources

### Primary (HIGH confidence)
- [Anthropic Structured Outputs Docs](https://platform.claude.com/docs/en/build-with-claude/structured-outputs) — `output_config.format`, supported models, schema limits, no-beta-header confirmation
- [Gemini OpenAI Compatibility Docs](https://ai.google.dev/gemini-api/docs/openai) — what is/is not supported on the compat endpoint
- [Celery 5 Tasks Documentation](https://docs.celeryq.dev/en/stable/userguide/tasks.html) — `bind=True`, `self.retry()`, `countdown`, `max_retries`
- [OpenAI Python SDK API Reference](https://developers.openai.com/api/reference/python) — AsyncOpenAI singleton/connection pool recommendation

### Secondary (MEDIUM confidence)
- [Groq Structured Outputs](https://console.groq.com/docs/structured-outputs) — `json_object` support confirmed
- [Mistral JSON Mode](https://docs.mistral.ai/capabilities/structured_output/json_mode) — `response_format={"type":"json_object"}` confirmed
- [OpenRouter Structured Outputs](https://openrouter.ai/docs/guides/features/structured-outputs) — `json_object` passthrough confirmed
- [openai-python Issue #3224](https://github.com/openai/openai-python/issues/3224) — empty api_key breaking change in 2.34.0
- [openai-python Issue #1725](https://github.com/openai/openai-python/issues/1725) — connection pool reuse requires singleton client
- [Anthropic SDK Client Architecture](https://deepwiki.com/anthropics/anthropic-sdk-python/4.2-synchronous-and-asynchronous-clients) — AsyncAnthropic httpx lifecycle

### Tertiary (LOW confidence)
- Ollama/LMStudio JSON mode behaviour — [ASSUMED] from known community patterns; not verified against official Ollama API docs in this session
- xAI/Grok json_object support — [ASSUMED]
- DeepSeek json_object support — [ASSUMED]

---

## Metadata

**Confidence breakdown:**
- Standard stack: HIGH — all packages already in requirements.txt, versions verified on PyPI
- Anthropic structured output (D-03): HIGH — verified against official Anthropic docs
- Client lifecycle (D-07): HIGH — verified against openai-python issues + Anthropic SDK docs
- JSON mode matrix: MEDIUM — Groq/Mistral/OpenRouter verified; xAI/DeepSeek/Gemini partially assumed
- Celery retry pattern: HIGH — verified against official Celery 5 docs
- system_settings design: HIGH — based on established cloud_utils.py pattern in codebase
- Pitfalls: HIGH — openai SDK empty key bug directly cited from issue tracker

**Research date:** 2026-06-02
**Valid until:** 2026-09-01 (stable APIs; Anthropic structured output is GA, no longer beta)
