# Phase 14 Summary: Provider-Agnostic LLM Factory

## Goal
Replace `ChatZhipuAI` (legacy JWT auth) with `init_chat_model(model_provider="openai", base_url=...)` making GLM work through OpenAI-compatible endpoints. This unblocks the pipeline end-to-end by routing requests to the wrong rate-limit tier.

## Files Changed
| File | Changes |
|------|---------|
| `src/unfamiliar/config.py` | Added `LLMConfig` model for `[llm]` section parsing; updated `resolve_api_key()` to accept `api_key_env_override` parameter |
| `src/unfamiliar/llm/providers/factory.py` | Complete rewrite: new `ProviderConfig` dataclass with `base_url`/`api_key_env` fields; new `get_provider_config()` merge function; single `create_chat_model()` code path; deleted legacy GLM functions |
| `src/unfamiliar/llm/structured.py` | Cleared `ChatZhipuAI` from denylist; added `provider_name` param to `create_structured_chain`; added `_should_use_structured_output` |
| `src/unfamiliar/llm/briefing/generator.py` | Passes `provider_name` to `create_structured_chain` |
| `src/unfamiliar/llm/missions/generator.py` | Passes `provider_name` to `create_structured_chain` (both single and multi-call) |
| `src/unfamiliar/campaign/persister.py` | Fixed `persist_briefing_data` to pull structural data (path, file_count, category_relevance) from `AnalysisResult` instead of `ModuleDescription`; added `analysis` parameter |
| `src/unfamiliar/campaign/orchestrator.py` | Passes `analysis` to `persist_briefing_data`; deletes existing campaign before recreating (overwrite semantics); removed `_resume_initialization` |
| `src/unfamiliar/db/repositories/campaign.py` | Added `CampaignRepository.delete()` with CASCADE |
| `pyproject.toml` | Added `langchain-openai>=0.3`; removed `pyjwt` |
| `tests/test_llm_provider.py` | Rewritten for new factory design (45 tests); updated default model references |
| `tests/test_llm_structured.py` | Updated denylist test for GLM tool-calling support |

## Key Functions

### `LLMConfig`
```python
class LLMConfig(BaseModel):
    model: str | None = None
    base_url: str | None = None
    api_key_env: str | None = None
    max_tokens: int | None = Field(default=None, gt=0)
```

### `ProviderConfig`
```python
@dataclass(frozen=True)
class ProviderConfig:
    langchain_provider: str      # "anthropic" or "openai"
    default_model: str            # "claude-sonnet-4-5-2025-929" or "glm-5"
    api_key_env: str              # "ANTHROPIC_API_KEY" or "ZHIPUAI_API_KEY"
    base_url: str | None = None   # "https://api.z.ai/api/coding/paas/v4"
    retry_base_delay: float = 1.0
    supports_tool_calling: bool = True  # False for GLM (rate-limits structured outputs)
```

### `get_provider_config(provider_name, config)`
Merges config overrides onto built-in provider defaults:
- `model`: `config.llm.model or default`
- `api_key_env`: `config.llm.api_key_env or default`
- `base_url`: `config.llm.base_url if set, else default

- `retry_base_delay`: Not user-configurable

### `create_chat_model(config, max_tokens?)`
Single code path through `init_chat_model`:
- Gets merged provider config
- Resolves API key with `api_key_env_override`
- Builds kwargs (model, model_provider, api_key, temperature, max_retries)
- Optionally adds `base_url` and `max_tokens`
- Calls `init_chat_model(**kwargs)`

### `resolve_api_key(provider, config, api_key_env_override?)`
Updated to check config-specified env var first, then legacy provider env var, then generic fallback.

Priority:
1. `api_key_env_override` (from provider registry)
2. Legacy `_PROVIDER_ENV_VARS` (e.g., `GLM_API_KEY`)
3. `UNFAMILIAR_API_KEY`

### `_resolve_max_tokens(config, max_tokens_override)`
Resolves max_tokens from override → config → env var → None
- Returns `None` if not set anywhere, letting model decide output length

- Priority: `max_tokens` param > `config.llm.max_tokens` > `UNFAMILIAR_MAX_TOKENS` > None

## Behavior Changes
- **GLM provider now uses `init_chat_model(model_provider="openai")` with `base_url`
- **Anthropic provider uses `init_chat_model(model_provider="anthropic")` without `base_url`
- **`max_tokens` is omitted from API call when not configured (model decides)
- **`ZHIPUAI_API_KEY` is primary env var for GLM (with `GLM_API_KEY` fallback)
- **GLM default model updated from `glm-4-flash` to `glm-5`
- **GLM uses JSON text fallback** for structured output (not `response_format` / Structured Outputs API). GLM rate-limits the structured outputs endpoint, causing 429 errors even on paid tiers. The `supports_tool_calling=False` flag on `ProviderConfig` routes GLM through the prompt-based JSON fallback path in `create_structured_chain`.
- **`create_structured_chain` accepts `provider_name`** — generators pass their existing `provider_name` parameter through to check provider-level tool calling support
- **GLM `base_url` uses Coding Plan endpoint** — `https://api.z.ai/api/coding/paas/v4` (user's Z.AI Coding Plan credits only authorize calls through coding-specific paths)
- **`persist_briefing_data` accepts `analysis`** — structural module data (path, file_count, category_relevance) comes from `AnalysisResult.modules`, not from `ModuleDescription` (which only has name, description, security_relevant)
- **`create_campaign` overwrites** — re-running `investigate` on the same repo deletes the existing campaign (CASCADE) and starts fresh, instead of attempting to resume

## Bugs Fixed During Review

**`retry.py` — `str.append()` crash on exhausted retries**

The phase 14 logging enhancement (adding response body to the exhausted-retries log) used `rate_msg.append(...)` on a string object. Strings don't have `.append()` — this caused `AttributeError` on the last retry attempt whenever the HTTP exception had a response body (i.e., every real 429 from GLM). The error was masked: the final `BriefingGenerationError` message said `"'str' object has no attribute 'append'"` instead of the real error. Also had a dead duplicate `break` statement.

Fixed: `rate_msg.append(x)` → `rate_msg += x`. Removed duplicate `break`.

**Test gap:** The existing exhausted-retries test uses a synthetic exception without `.response.text`, so `body` is always `None` and the bug was never triggered. Phase 18 should add a test with a realistic HTTP exception that has a response body.

**`persister.py` — `ModuleDescription` attribute mismatch**

`persist_briefing_data` accessed `module_desc.category_relevance`, `module_desc.path`, and `module_desc.file_count` on `ModuleDescription` objects. These fields don't exist on the LLM schema — they exist on `DetectedModule` (from analysis). The bug was latent because the pipeline never reached the persister (blocked by 429 errors).

Fixed: Added `analysis: AnalysisResult | None = None` parameter. Structural data is pulled from `analysis.modules[idx]` by index. Defaults used when analysis is unavailable or index is out of range.

**`orchestrator.py` — `UNIQUE constraint failed` on re-investigation**

Re-running `investigate` on the same repo found an INITIALIZING campaign and called `_resume_initialization`, which re-ran the full pipeline from scratch — including `create_briefing()`. Since the briefing from the previous (crashed) run already existed, this hit `UNIQUE constraint failed: briefing.campaign_id`.

Fixed: `create_campaign` now deletes the existing campaign (CASCADE cleans all related data) before creating a new one. Removed `_resume_initialization`. Added `CampaignRepository.delete()` method.

## Test Coverage
- 45 provider tests covering:
  - Config-driven provider selection
  - Case insensitivity
  - Unknown provider errors
  - Missing API key errors
  - `init_chat_model` import errors
  - Retry delay lookup
  - `get_provider_config` merge logic
  - `max_tokens` handling
  - `base_url` handling
  - Model env var override
- 32 config tests covering:
  - `[llm]` section parsing
  - Backward compatibility
  - API key resolution
- 888 total tests passing
