# Spec: LLMResult Protocol Migration (Benchmark Infra Part 1)

> Generated via /vibeflow:gen-spec on 2026-03-22
> PRD: `.vibeflow/prds/benchmark-infrastructure.md`
> Budget: ≤ 15 files (cross-cutting protocol change — budget override justificado)

## Objective

`LLMProvider.complete()` passa a retornar `LLMResult(text, usage)` em vez de `str`, alinhando o SDK com o padrão de mercado (OpenAI, Anthropic, LiteLLM) e habilitando token tracking.

## Context

O `LLMProvider.complete()` retorna `str` — um atalho do v0 que descarta metadata de usage. Sem token counts, é impossível comparar custo entre estratégias de extração. O SDK não tem usuários externos, breaking change é aceitável. A migração é mecânica: todo caller muda de `raw = await llm.complete(...)` para `result = await llm.complete(...); raw = result.text`.

## Definition of Done

1. **[ ] `LLMResult` e `TokenUsage` existem** — Dataclasses em `protocols.py` com campos `text: str`, `usage: TokenUsage | None` e `input_tokens: int`, `output_tokens: int`, `total_tokens: int`.
2. **[ ] `LLMProvider.complete()` retorna `LLMResult`** — Assinatura no Protocol atualizada. Type check passa.
3. **[ ] `OpenAIProvider` retorna `LLMResult` com usage** — `response.usage.prompt_tokens` e `completion_tokens` mapeados pro `TokenUsage`.
4. **[ ] Todos os 16 call sites migrados** — Cada `await llm.complete(...)` usa `result.text` em vez de `str` direto. Zero `str` return em call sites.
5. **[ ] `FakeLLMProvider` retorna `LLMResult`** — `conftest.py` atualizado. Todos os 356+ testes passam.
6. **[ ] Exports públicos atualizados** — `LLMResult` e `TokenUsage` exportados em `__init__.py`.

## Scope

### 1. `src/arandu/protocols.py`

```python
@dataclass
class TokenUsage:
    input_tokens: int = 0
    output_tokens: int = 0
    total_tokens: int = 0

    def __add__(self, other: "TokenUsage") -> "TokenUsage":
        return TokenUsage(
            input_tokens=self.input_tokens + other.input_tokens,
            output_tokens=self.output_tokens + other.output_tokens,
            total_tokens=self.total_tokens + other.total_tokens,
        )

@dataclass
class LLMResult:
    text: str
    usage: TokenUsage | None = None

class LLMProvider(Protocol):
    async def complete(self, messages, temperature, response_format, max_tokens) -> LLMResult: ...
```

`TokenUsage.__add__` permite acumular usage: `total_usage += result.usage`.

### 2. `src/arandu/providers/openai.py`

Mapear `response.usage` da OpenAI API pro `TokenUsage`. Retornar `LLMResult(text=..., usage=...)`.

### 3. Caller migration (10 source files, 16 call sites)

Cada call site é uma mudança mecânica:

```python
# Antes
raw = await llm.complete(messages=[...], ...)

# Depois
result = await llm.complete(messages=[...], ...)
raw = result.text
```

Files:
- `src/arandu/write/extract.py` (2 sites)
- `src/arandu/write/entity_resolution.py` (1 site)
- `src/arandu/write/reconcile.py` (1 site)
- `src/arandu/read/retrieval_agent.py` (1 site)
- `src/arandu/read/reranker.py` (1 site)
- `src/arandu/read/procedural.py` (2 sites)
- `src/arandu/background/memify.py` (1 site)
- `src/arandu/background/consolidation.py` (3 sites)
- `src/arandu/background/clustering.py` (3 sites)
- `src/arandu/background/sleep_time.py` (1 site)

### 4. `tests/conftest.py`

`FakeLLMProvider.complete()` retorna `LLMResult(text=response)` em vez de `str`.

### 5. `src/arandu/__init__.py`

Exportar `LLMResult`, `TokenUsage`.

## Anti-scope

- **Token accumulation nos pipelines** — Part 2. Esta spec só migra a interface.
- **`tokens_used` no WriteResult/RetrieveResult** — Part 2.
- **`config_overrides` no write()** — Part 2.
- **`dry_run`** — Part 2.
- **Docs** — Part 2 (quando tudo funciona).
- **`EmbeddingProvider` changes** — Fora de escopo.
- **Backward compatibility shim** — Sem usuários externos.

## Technical Decisions

### TD-1: `LLMResult` como dataclass, não NamedTuple
**Trade-off:** NamedTuple seria imutável e mais leve. Dataclass permite defaults e `__add__` no `TokenUsage`.
**Decisão:** Dataclass. Consistente com o resto do codebase (RetrievalPlan, WriteResult, etc.).

### TD-2: `usage: TokenUsage | None` em vez de `usage: TokenUsage`
**Trade-off:** Default `None` vs default `TokenUsage(0,0,0)`.
**Decisão:** `None` — indica explicitamente que o provider não reportou usage. Diferente de "reportou 0 tokens". Callers que acumulam fazem `if result.usage: total += result.usage`.

### TD-3: `TokenUsage.__add__` pra acumulação
**Trade-off:** Poderia usar função helper separada.
**Decisão:** `__add__` é idiomático Python: `total += result.usage`. Menos boilerplate nos pipelines.

### TD-4: Budget override (15 files vs 6)
**Justificativa:** Migração de Protocol é cross-cutting por natureza. 10 source files + conftest + protocols + openai provider + __init__ = 14 files mínimo. Não faz sentido splittar uma migração mecânica em 3 specs — cada spec dependeria da anterior e o diff seria incompilável entre parts.

## Applicable Patterns

- **Dependency Injection** — `LLMProvider` é Protocol-based. A mudança mantém structural subtyping.
- **Error Handling** — Fail-safe: callers que wrappam em try/except continuam funcionando. `result.text` é sempre `str`.
- **Testing** — `FakeLLMProvider` atualizado garante que todos os testes passam sem mudança de lógica.

## Risks

1. **Risco: miss de call site** — Mitigação: mypy vai reclamar de qualquer site que trata o retorno como `str` (método `.strip()`, `.startswith()`, etc. não existem em `LLMResult`). Type checking é o safety net.
2. **Risco: testes quebram em cascata** — Mitigação: mudar `FakeLLMProvider` primeiro, depois rodar testes incrementalmente.
3. **Risco: OpenAI usage retorna None em streaming** — Mitigação: o SDK não usa streaming. Non-issue.
