# jangada — documentação completa (llms-full.txt)

> Camada fina e adaptável sobre os SDKs oficiais de LLM (Anthropic, OpenAI,
> Groq, Gemini, Mistral, Ollama...). Troque provider/model/api_key sem mudar o resto do código.
> Este arquivo concatena toda a documentação de docs/ para consumo em um
> único fetch. Instalação: pip install jangada-ai (importa-se como import jangada_ai).

Fonte: https://github.com/nerigleston/jangada — índice curto em llms.txt.

---

# Começando com jangada

[![Open In Colab](https://colab.research.google.com/assets/colab-badge.svg)](https://colab.research.google.com/github/nerigleston/jangada-docs/blob/main/examples/notebooks/jangada_quickstart.ipynb)

`jangada` é uma camada fina sobre os SDKs oficiais de LLM (Anthropic, OpenAI,
Groq, Gemini). O objetivo é trocar **provider / model / api_key** sem mudar o
resto do código.

> Quer só testar? Abra o **[quickstart no Colab](https://colab.research.google.com/github/nerigleston/jangada-docs/blob/main/examples/notebooks/jangada_quickstart.ipynb)** (1 clique, cole sua chave e rode).

## Instalação

```bash
pip install jangada-ai                 # nome no PyPI; importa-se como jangada_ai
pip install "jangada-ai[anthropic]"    # só Claude
pip install "jangada-ai[openai,groq]"  # OpenAI + Groq
pip install "jangada-ai[all]"          # todos os SDKs
pip install "jangada-ai[files]"        # leitura de docx/pdf/csv/xlsx
```

> O nome de distribuição é `jangada-ai` (o nome `jangada` estava ocupado no
> PyPI). O pacote é importado como `import jangada_ai` (hífen vira underscore).

Imports são preguiçosos: `import jangada_ai` funciona sem nenhum SDK instalado.

## Primeira chamada

```python
from jangada_ai import LLM

llm = LLM("anthropic", "claude-opus-4-8")
print(llm.complete("Explique {{tema}} em 2 frases.", tema="MCP").text)
```

`complete()` aceita templates `{{ }}` direto no prompt — as variáveis vêm como
keyword args. Veja [Parâmetros de geração](parameters.md) para controlar
`temperature`, `max_tokens`, etc.

## Trocar de provider

Só muda os dois primeiros argumentos; o resto do código permanece:

```python
LLM("openai", "gpt-4o-mini")
LLM("groq", "llama-3.3-70b-versatile")
LLM("gemini", "gemini-2.5-flash")
```

Chaves de API vêm de `api_key=`, da variável de ambiente do provider, ou de um
arquivo `.env` detectado na importação. Precedência:
`api_key=` explícito > variável de ambiente > `.env`.

## Extras e ciclo de vida (1.9.0)

Extras novos: `jangada-ai[ollama]` (provider Ollama) e `pypdfium2` dentro de
`[files]` (PDF escaneado em vision). O `[mcp]` aceita `mcp` 1.x e 2.x, e o `[all]`
inclui ollama e mcp.

O `LLM` fecha as conexões HTTP do SDK com `close()`/`aclose()` ou como context
manager — útil em workers e processos de vida longa:

```python
from jangada_ai import LLM

async with LLM("openai", "gpt-5-mini") as llm:
    print((await llm.acomplete("Oi!")).text)
```

## Próximos passos

- [Providers e chaves](providers.md)
- [Structured output](structured-output.md)
- [Documentos (docx/pdf/csv/xlsx)](documents.md)
- [Retry e fallback](retry-fallback.md)

---

# Tutorial: primeiros passos (do zero ao fallback)

Um passo a passo de ~10 minutos: instalar, fazer a primeira chamada, trocar de
provider sem mudar o código, usar templates e deixar tudo resiliente com
fallback. Cada bloco é executável.

## 1. Instalar

Instale o núcleo + o SDK do provider que você vai usar (extras opcionais):

```bash
pip install "jangada-ai[openai]"     # ou [anthropic] / [groq] / [gemini] / [all]
export OPENAI_API_KEY=sk-...
```

> `import jangada_ai` funciona sem nenhum SDK instalado — você só instala o extra
> de quem for usar.

## 2. Primeira chamada

```python
from jangada_ai import LLM

llm = LLM("openai", "gpt-4o-mini")
print(llm.complete("Explique o que é uma jangada em uma frase.").text)
```

`complete()` devolve um `Completion`. Além de `.text`, ele traz `.usage` (tokens),
`.cost` (custo estimado), `.provider`, `.model` e o objeto nativo em `.raw`.

## 3. Trocar de provider — o pitch

A mesma chamada vale para os quatro. Só muda o par `(provider, modelo)`:

```python
LLM("anthropic", "claude-opus-4-8").complete("Oi!")
LLM("groq", "llama-3.3-70b-versatile").complete("Oi!")
LLM("gemini", "gemini-2.5-flash").complete("Oi!")
```

Você **não** muda mais nada — params, erros e custo são normalizados por baixo.
Veja [Providers e chaves](providers.md).

## 4. Templates `{{ }}`

Em vez de montar strings na mão, passe variáveis como kwargs:

```python
llm.complete("Traduza para {{idioma}}: {{frase}}",
             idioma="francês", frase="Bom dia")
```

## 5. Async

Todo método tem versão `a*` equivalente:

```python
import asyncio

async def main():
    comp = await llm.acomplete("Diga olá.")
    print(comp.text)

asyncio.run(main())
```

## 6. Resiliência: retry + fallback

Em produção, um provider pode dar rate limit ou 5xx. Defina um reserva:

```python
primario = LLM("openai", "gpt-4o-mini")
reserva  = LLM("anthropic", "claude-haiku-4-5-20251001")

llm = primario.with_fallback(reserva)
comp = llm.complete("...")      # tenta o primário (com retries); se falhar, vai pro reserva
print(comp.provider, comp.model, comp.cost)
```

A jangada tenta o primário com **backoff** e só cai pro reserva em erros
"failover-able" (rate limit, timeout, 5xx, 404). Detalhes em
[Retry e fallback](retry-fallback.md) e [Custo e tokens](cost.md).

## Próximos passos

- [Extração estruturada](tutorial-extracao-estruturada.md) — texto/imagem → Pydantic.
- [RAG do zero](tutorial-rag.md) — responder com base nos seus documentos.
- [Agente MCP do zero](tutorial-agente-mcp.md) — o modelo usando ferramentas sozinho.
- Receitas completas em [`examples/cookbook/`](https://github.com/nerigleston/jangada/tree/master/examples/cookbook).

---

# Tutorial: extração estruturada (texto e imagem → Pydantic)

Objetivo: transformar texto solto ou uma **foto** num objeto Pydantic validado,
com uma só chamada `parse()`. Mesmo código em qualquer provider — a jangada cuida
do mecanismo (OpenAI `.parse`, Groq json_schema, Gemini `response_schema`,
Anthropic tool-forcing).

## 1. Defina o que você quer extrair

O schema é um modelo Pydantic. Use `Field(description=...)` para guiar o modelo:

```python
from pydantic import BaseModel, Field

class Item(BaseModel):
    descricao: str
    quantidade: float
    valor_total: float

class NotaFiscal(BaseModel):
    estabelecimento: str
    cnpj: str | None = Field(default=None, description="CNPJ como aparece")
    itens: list[Item]
    valor_a_pagar: float
```

## 2. Extrair de texto

```python
from jangada_ai import LLM

llm = LLM("openai", "gpt-4o-mini")
nota = llm.parse("Padaria do Zé — 2 pães R$8, 1 leite R$6. Total R$14.", NotaFiscal).parsed
print(nota.estabelecimento, nota.valor_a_pagar)   # Padaria do Zé 14.0
```

`parse()` devolve um `Completion`; o objeto validado fica em `.parsed`.

## 3. Extrair de uma imagem (vision + structured)

A mesma chamada aceita `images=` — vision e structured output juntos:

```python
nota = llm.parse(
    "Extraia os dados desta nota fiscal. Transcreva exatamente; não invente.",
    NotaFiscal,
    images=["nota.jpg"],     # caminho, bytes ou base64
).parsed

for i in nota.itens:
    print(f"{i.descricao}: {i.quantidade:g} = {i.valor_total:.2f}")
```

Use um modelo com visão (gpt-4o-mini, gemini-2.5-flash, claude-haiku-4-5, ou um
multimodal do Groq como `meta-llama/llama-4-scout-17b-16e-instruct`).

## 4. Funciona em qualquer provider

```python
for provider, modelo in [("openai", "gpt-4o-mini"),
                         ("gemini", "gemini-2.5-flash"),
                         ("anthropic", "claude-haiku-4-5-20251001")]:
    p = LLM(provider, modelo).parse("João tem 30 anos.", NotaFiscal)  # exemplo simplificado
```

> **Groq:** modelos sem `json_schema` (ex.: `llama-3.3-70b`) caem automaticamente
> para JSON Object mode — `parse()` funciona mesmo assim.

## Próximos passos

- Receita completa: [`examples/cookbook/01_extracao_nota_fiscal.py`](https://github.com/nerigleston/jangada/blob/master/examples/cookbook/01_extracao_nota_fiscal.py).
- Referência: [Structured output](structured-output.md) e [Vision](vision.md).
- Para documentos (docx/pdf/xlsx) sem vision, veja [Documentos](documents.md).

---

# Tutorial: RAG do zero

Objetivo: responder perguntas com base nos **seus** textos. A jangada faz
embeddings + busca **híbrida** (BM25 lexical + vetorial, fundidos por RRF) +
geração da resposta. Comece em memória (sem banco) e troque por um vector store
real depois — sem mudar o resto.

Instale o extra:

```bash
pip install "jangada-ai[openai,rag]"
export OPENAI_API_KEY=sk-...
```

## 1. Montar o RAG

Você precisa de um **embedder** (gera vetores), um **store** (guarda) e um
**chat** (responde):

```python
from jangada_ai import LLM
from jangada_ai.rag import RAG, InMemoryVectorStore

embedder = LLM("openai", "text-embedding-3-small")
chat     = LLM("openai", "gpt-4o-mini")

rag = RAG(embedder, InMemoryVectorStore(), chat=chat, k=3, alpha=0.5)
```

- `k` — quantos trechos recuperar.
- `alpha` — balanço da busca: `0` = só BM25 (palavras), `1` = só vetorial
  (semântica), `0.5` = equilíbrio.

## 2. Indexar conteúdo

```python
rag.add_texts([
    "A jangada troca de provider mudando só LLM('provider', 'modelo').",
    "O fallback é acionado em rate limit, 5xx ou timeout.",
])

# ou a partir de arquivos (docx/pdf/csv/xlsx/txt):
rag.add_document("manual.pdf")
```

## 3. Perguntar

```python
resp = rag.ask("Como troco de provider?")
print(resp.text)                 # resposta com base no contexto recuperado
print(len(resp.sources), "trechos usados")
```

`ask()` recupera os trechos mais relevantes, monta o contexto e gera a resposta.
`resp.sources` traz os trechos (com score) que embasaram.

## 4. Indo para produção: vector store real

Troque só o store — pgvector ou Mongo, detectado pela string de conexão:

```python
from jangada_ai.rag import vector_store

store = vector_store("postgresql://user:pass@host/db")   # ou "mongodb+srv://..."
rag = RAG(embedder, store, chat=chat)
```

O resto do código continua igual. Ajustes finos (`min_score`, `filter`, `weights`,
`max_context_chars`) estão em [RAG](rag.md).

## Versão assíncrona (1.9.0+)

Todo o fluxo tem par async — útil em APIs (FastAPI) e para indexar muitos
documentos sem bloquear o event loop:

```python
await rag.aadd_document("politica.pdf")
resp = await rag.aask("Posso devolver depois de 30 dias?")
```

No modo híbrido (padrão), `min_score` é comparado com a **similaridade de cosseno**
da busca vetorial — valores como `0.3`–`0.5` fazem sentido.

## Próximos passos

- Receita completa: [`examples/cookbook/03_chatbot_rag.py`](https://github.com/nerigleston/jangada/blob/master/examples/cookbook/03_chatbot_rag.py).
- Referência detalhada: [RAG](rag.md).

---

# Tutorial: agente MCP do zero

Objetivo: conectar num servidor **MCP** (Model Context Protocol) e deixar o modelo
**usar as ferramentas sozinho** — ele decide qual tool chamar, a jangada executa e
reenvia, até a resposta final. Funciona em qualquer provider (não só os com MCP
nativo), porque usa o tool calling comum.

Instale o extra (traz o pacote oficial `mcp`):

```bash
pip install "jangada-ai[openai,mcp]"
export OPENAI_API_KEY=sk-...
```

## 1. Conectar num servidor MCP

`MCPClient` conecta por **URL** (streamable-http) ou **stdio** (subprocesso):

```python
import asyncio
from jangada_ai.mcp import MCPClient

async def main():
    async with MCPClient("https://seu-mcp/mcp/") as mcp:     # remoto
        tools = await mcp.list_tools()
        print([t.name for t in tools])

asyncio.run(main())

# stdio (servidor local como subprocesso):
# async with MCPClient(command="python", args=["server.py"]) as mcp: ...
```

## 2. Rodar o agente

`run_agent` faz o loop completo: lista as tools, manda pro modelo, executa as
chamadas e reenvia, até a resposta final.

```python
from jangada_ai import LLM
from jangada_ai.mcp import MCPClient, run_agent

async def main():
    llm = LLM("openai", "gpt-4o-mini")
    async with MCPClient("https://seu-mcp/mcp/") as mcp:
        comp = await run_agent(llm, "Quais são os dados da empresa?", client=mcp)
        print(comp.text)

asyncio.run(main())
```

O modelo escolhe a tool certa (ex.: `obter_dados_empresa`), a jangada executa via
MCP e devolve o resultado pro modelo, que responde em linguagem natural.

## 3. Além de tools: resources e prompts

O `MCPClient` cobre todos os primitivos do MCP:

```python
async with MCPClient("https://seu-mcp/mcp/") as mcp:
    texto = await mcp.resource_text("file:///guia.md")        # dados/contexto do server
    msgs  = await mcp.prompt_messages("revisar", {"x": "..."}) # template do server -> list[Message]
    resp  = await llm.acomplete(None, history=msgs)
```

E recursos de **cliente** (o server chama de volta): `roots=[...]`,
`sampling_llm=LLM(...)` (o server pede uma geração ao **seu** LLM),
`elicitation_callback`, `logging_callback`.

## Próximos passos

- Receita completa: [`examples/cookbook/02_agente_mcp.py`](https://github.com/nerigleston/jangada/blob/master/examples/cookbook/02_agente_mcp.py).
- Referência detalhada: [MCP](mcp.md) e [Tools](tools.md).

---

# Providers e chaves de API

A jangada suporta sete providers, cada um isolado em um *adapter* que traduz
os tipos normalizados (`Message`/`Completion`) para o SDK nativo.

| Provider    | `provider=`  | Variável de ambiente | Extra para instalar         |
|-------------|--------------|----------------------|-----------------------------|
| Anthropic   | `anthropic`  | `ANTHROPIC_API_KEY`  | `jangada-ai[anthropic]`     |
| OpenAI      | `openai`     | `OPENAI_API_KEY`     | `jangada-ai[openai]`        |
| Groq        | `groq`       | `GROQ_API_KEY`       | `jangada-ai[groq]`          |
| Gemini      | `gemini`     | `GEMINI_API_KEY`     | `jangada-ai[gemini]`        |
| Mistral     | `mistral`    | `MISTRAL_API_KEY`    | `jangada-ai[mistral]`       |
| OpenRouter  | `openrouter` | `OPENROUTER_API_KEY` | `jangada-ai[openai]`        |
| DeepSeek    | `deepseek`   | `DEEPSEEK_API_KEY`   | `jangada-ai[openai]`        |
| Ollama      | `ollama`     | `OLLAMA_API_KEY` (opcional; local não usa) | `jangada-ai[ollama]` |

## OpenRouter (gateway para centenas de modelos)

[OpenRouter](https://openrouter.ai) é um *gateway* compatível com o dialeto
`chat.completions` da OpenAI — por isso reusa o **mesmo SDK `openai`** (extra
`jangada-ai[openai]`), só apontando para outro `base_url`. Dá acesso a centenas
de modelos de vários provedores com uma única chave.

```python
LLM("openrouter", "openai/gpt-4o")                       # usa OPENROUTER_API_KEY
LLM("openrouter", "anthropic/claude-sonnet-4.6")
LLM("openrouter", "google/gemini-2.5-flash", api_key="sk-or-...")
```

O modelo é **qualificado por provedor** (`provedor/modelo`). Cabeçalhos opcionais
de ranking vão pelo cliente; argumentos exclusivos do OpenRouter (ex.: `models`
para roteamento com fallback) vão por `extra=`:

```python
LLM(
    "openrouter", "openai/gpt-4o",
    default_headers={"HTTP-Referer": "https://meusite.com", "X-Title": "Meu App"},
    extra={"models": ["openai/gpt-4o", "anthropic/claude-sonnet-4.6"]},
)
```

Suporta texto, streaming, structured output (json_schema com fallback automático
para JSON Object mode), vision, tools/function calling, **transcrição de áudio**
(modelos `openai/whisper-*`, `openai/gpt-4o-transcribe`, ...) e **embeddings**
(`openai/text-embedding-3-*`, `google/gemini-embedding-001`, `qwen/qwen3-embedding-*`,
...). O único recurso não suportado é **MCP server-side**: ele depende da Responses
API da OpenAI, que o OpenRouter não expõe — esse caminho levanta `UnsupportedError`.

## DeepSeek (raciocínio + thinking mode)

[DeepSeek](https://api-docs.deepseek.com) também fala `chat.completions` num
`base_url` próprio — mesma receita do OpenRouter, reusa o SDK `openai`.

```python
LLM("deepseek", "deepseek-v4-flash")   # rápido/barato
LLM("deepseek", "deepseek-v4-pro")     # raciocínio (thinking mode)
```

O modo `thinking` do `deepseek-v4-pro`/`-flash` é um campo fora do schema
típado do SDK oficial da OpenAI — o SDK exige `extra_body` pra aceitá-lo. Passe
por `extra=` normalmente; o adapter empacota em `extra_body` sozinho:

```python
LLM("deepseek", "deepseek-v4-pro", extra={
    "thinking": {"type": "enabled", "reasoning_effort": "high"},  # low/high/max
})
```

Nesse modo a API rejeita `temperature`/`top_p`/`presence_penalty`/
`frequency_penalty` — não passe esses params junto.

Suporta texto, streaming, vision (`deepseek-v4-flash-vision-exp`, mesmo caminho
de `images=` dos outros providers OpenAI-compatíveis) e tools/function calling.
**Não** suporta: JSON Schema estrito (a doc é explícita — "does not offer a
schema-based mode"; `parse`/`aparse` vão direto pro JSON Object mode), MCP
server-side (a Responses API do DeepSeek só tem `function`/`web_search`) e
transcrição de áudio (sem endpoint documentado) — os três levantam
`UnsupportedError`/erro claro em vez de falhar silenciosamente.

## Resolução da chave

```python
LLM("openai", "gpt-4o-mini", api_key="sk-...")   # explícito
LLM("openai", "gpt-4o-mini")                       # usa OPENAI_API_KEY ou .env
```

Precedência: **`api_key=` explícito > variável de ambiente > arquivo `.env`**.
O `.env` é detectado de forma não-destrutiva na importação (desative com
`JANGADA_NO_DOTENV=1`).

## Como cada adapter trata structured output

- **OpenAI**: `chat.completions.parse(response_format=Modelo)` → `.message.parsed`
- **Groq**: `response_format={"type":"json_schema",...}` + `model_validate_json`
- **Gemini**: `config.response_schema=Modelo` → `resp.parsed`
- **Anthropic**: tool-forcing (`tool_choice` fixo) → valida `tool_use.input`
- **Mistral**: helper nativo `chat.parse(response_format=Modelo)` → `.message.parsed`
- **OpenRouter**: igual ao Groq (json_schema com fallback p/ JSON Object mode)
- **DeepSeek**: sem json_schema — vai direto pro JSON Object mode (sem tentar
  json_schema primeiro, diferente do fallback do Groq/OpenRouter)

Veja [Structured output](structured-output.md) para o uso uniforme.

## Adicionando um provider novo

Se ele falar o dialeto `chat.completions` da OpenAI, herde de
`_OpenAICompatible` e ajuste `sdk_module`/`sync_class`/`async_class`. Caso
contrário, implemente os 6 métodos do contrato `Provider`. Detalhes em
[Estendendo](extending.md).

---

# Matriz de capacidades por provider

O que cada provider suporta na jangada. Os recursos da API pública são os
mesmos (`complete`, `parse`, `stream`, `transcribe`, ...); o que muda é o que
cada provider consegue fazer por baixo.

| Recurso                         | OpenAI | Groq | Gemini | Anthropic | Mistral |
|---------------------------------|:------:|:----:|:------:|:---------:|:-------:|
| Texto (`complete`/`acomplete`)  | ✅     | ✅   | ✅     | ✅        | ✅      |
| Structured output (`parse`)     | ✅     | ✅   | ✅     | ✅        | ✅      |
| Tools / function calling        | ✅     | ✅   | ✅     | ✅        | ✅      |
| MCP (`mcp_servers=`)            | ✅ URL | ✅ URL | ✅ sessão⁴ | ✅ URL | ❌      |
| Tools nativas (`web_search()`…)⁵ | ✅ (Responses) | ⚠️ compound/gpt-oss | ✅ | ✅ | ✅ (Conversations) |
| Embeddings (`embed`)            | ✅     | ❌   | ✅     | ❌        | ✅      |
| Streaming (`stream`/`astream`)  | ✅     | ✅   | ✅     | ✅        | ✅      |
| Vision / imagens (`images=`)    | ✅     | ⚠️¹  | ✅     | ✅        | ⚠️¹     |
| Documentos (`files=`)²          | ✅     | ✅   | ✅     | ✅        | ✅      |
| Detecção de objetos             | ✅     | ⚠️¹  | ✅³    | ⚠️        | ⚠️      |
| Transcrição de áudio (`transcribe`) | ✅ | ✅   | ✅     | ❌        | ✅ (Voxtral) |
| OCR / Document AI (`ocr`)       | ❌     | ❌   | ❌     | ❌        | ✅      |
| Param `top_k`                   | ❌     | ❌   | ✅     | ✅        | ❌      |
| Param `seed`                    | ✅     | ✅   | ✅     | ❌        | ✅ (`random_seed`) |
| Param `stop`                    | ✅     | ✅   | ✅ (`stop_sequences`) | ✅ (`stop_sequences`) | ✅ |

¹ Depende do modelo: vision no Groq exige um modelo com visão (ex.: família
Llama vision); modelos de texto puro não aceitam imagem.
² `files=` extrai texto **localmente** (docx/pdf/csv/xlsx) e envia como texto —
por isso funciona em todos. Veja [Documentos](documents.md).
³ A convenção de bounding box (0–1000) é nativa do Gemini, que é o mais preciso.
⁴ MCP no Gemini é **client-side por sessão** e só no async (`acomplete`); os
demais (exceto Mistral) são **remoto por URL** (server-side). Veja [MCP](mcp.md).
⁵ Tools executadas pelo provider (busca na web, url context, code execution,
file search…). O suporte varia por tool e por modelo; também existem no
OpenRouter, no Bedrock (Amazon Nova) e no [Ollama](llm-ollama.md) (executadas
pelo adapter). Matriz completa em [Tools nativas](native-tools.md).

## Como cada um implementa o structured output

| Provider  | Mecanismo                                                |
|-----------|----------------------------------------------------------|
| OpenAI    | `chat.completions.parse(response_format=Modelo)`         |
| Groq      | `response_format={"type":"json_schema",...}` + validação |
| Gemini    | `config.response_schema=Modelo` → `resp.parsed`          |
| Anthropic | tool-forcing (`tool_choice` fixo) → valida `tool_use`    |
| Mistral   | helper nativo `chat.parse(response_format=Modelo)` → `.message.parsed` |

## Detalhe por provider

- [OpenAI](llm-openai.md)
- [Groq](llm-groq.md)
- [Gemini](llm-gemini.md)
- [Anthropic](llm-anthropic.md)
- [Mistral](llm-mistral.md)
- [Ollama](llm-ollama.md) (modelos locais e Ollama Cloud)

Os parâmetros canônicos e os perfis por modelo (gpt-5, gemini-3.x) estão em
[Parâmetros e perfis](parameters.md).

---

# OpenAI

Provider `openai`. Adapter sobre o SDK `openai` (dialeto `chat.completions`).

```bash
pip install "jangada-ai[openai]"
```

- **`provider=`**: `"openai"`
- **Variável de ambiente**: `OPENAI_API_KEY`
- **Base do adapter**: `_OpenAICompatible` (compartilhado com Groq)

```python
from jangada_ai import LLM
llm = LLM("openai", "gpt-4o-mini")
```

## O que faz

- **Texto** (`complete`/`acomplete`) e **streaming** (`stream`/`astream`).
- **Structured output** (`parse`): usa o helper nativo
  `chat.completions.parse(response_format=Modelo)` → `.message.parsed`.
- **Vision** (`images=`): imagens viram `image_url` com data URI.
- **Documentos** (`files=`): extração de texto local (comum a todos).
- **Detecção de objetos** (`detect_objects`): via vision + structured.
- **Transcrição de áudio** (`transcribe`): endpoint dedicado
  `audio.transcriptions.create`. Modelos: `gpt-4o-transcribe`,
  `gpt-4o-mini-transcribe`, `whisper-1`.

## Estrutura e quirks

- **Parâmetros**: aceita `temperature`, `max_tokens`, `top_p`, `stop`, `seed`.
  **Não** tem `top_k` (é descartado).
- **Perfil de modelo** (`profiles.py`): a família `gpt-5` rejeita `temperature`
  e usa `max_completion_tokens` em vez de `max_tokens` — a jangada normaliza
  isso automaticamente. Veja [Parâmetros e perfis](parameters.md).
- **Resposta** (`Completion`): `text`, `usage` (`input_tokens`/`output_tokens`
  derivados de `prompt_tokens`/`completion_tokens`), `raw` com o objeto nativo.
- **Erros**: traduzidos por `errors.classify()` para a hierarquia normalizada.

## O que mudou na 1.9.0

- **Tools nativas → Responses API.** Com `web_search()`, `file_search()`,
  `code_execution()` (code interpreter), `image_generation()` ou `computer_use()`
  em `tools=`, a chamada vai pela Responses API (veja [Tools nativas](native-tools.md)).
  Sem tools nativas, continua no chat.completions.
- **Structured output strict**: o schema passa a listar todas as propriedades em
  `required` (exigência do modo strict), e o fallback para JSON mode cobre mais
  mensagens de "schema não suportado".
- **Saída cortada no `parse`** vira `TruncatedError`; recusa (`refusal`) vira
  `OutputValidationError`.
- **Usage**: `cache_read_tokens` (prompt caching) e `reasoning_tokens` (modelos de
  raciocínio) no `usage`; o custo aplica o desconto de cache.
- **Embeddings em lote**: listas grandes são fatiadas em várias requisições (`llm.embed(textos, batch_size=...)`).
- **Série o (o1/o3/o4)** tem regra de perfil: `max_completion_tokens` e sem sampling.

## Exemplo de structured

```python
from pydantic import BaseModel
class Pessoa(BaseModel):
    nome: str; idade: int

llm.parse("Extraia: João, 30 anos.", Pessoa).parsed   # Pessoa(nome='João', idade=30)
```

Relacionado: [Matriz de capacidades](capabilities.md), [Groq](llm-groq.md)
(mesmo dialeto), [Transcrição de áudio](audio.md).

---

# Groq

Provider `groq`. Adapter sobre o SDK `groq`, que fala o mesmo dialeto
`chat.completions` da OpenAI — por isso herda de `_OpenAICompatible`.

```bash
pip install "jangada-ai[groq]"
```

- **`provider=`**: `"groq"`
- **Variável de ambiente**: `GROQ_API_KEY`
- **Diferença para a OpenAI**: `supports_parse_helper = False` (não tem
  `.parse()` nativo).

```python
from jangada_ai import LLM
llm = LLM("groq", "llama-3.3-70b-versatile")
```

## O que faz

- **Texto** e **streaming** — foco em latência muito baixa.
- **Structured output** (`parse`): como não há helper `.parse`, usa
  `response_format={"type":"json_schema",...}` e valida com
  `model_validate_json`.
- **Vision** (`images=`): suportado **apenas em modelos com visão** (ex.:
  família Llama vision). Modelos de texto puro recusam imagem.
- **Documentos** (`files=`): extração de texto local.
- **Transcrição de áudio** (`transcribe`): endpoint dedicado (compatível com o
  da OpenAI). Modelos: `whisper-large-v3`, `whisper-large-v3-turbo` (rápido).

## Estrutura e quirks

- **Parâmetros**: `temperature`, `max_tokens`, `top_p`, `stop`, `seed`.
  Sem `top_k`.
- **Mesma base da OpenAI**: a tradução de mensagens, structured e áudio
  reaproveitam `_OpenAICompatible`; só mudam `sdk_module`/`sync_class`/
  `async_class`/`supports_parse_helper`.
- **Resposta** (`Completion`): igual à OpenAI (`text`, `usage`, `raw`).

## Quando escolher Groq

Velocidade e custo de inferência baixos — ótimo para transcrição em lote
(`whisper-large-v3-turbo`) e respostas de baixa latência. Combine como
**fallback** ou **primário** com OpenAI (mesmo dialeto). Veja
[Transcrição de áudio](audio.md) e [Retry e fallback](retry-fallback.md).

## O que mudou na 1.9.0

- **Tools nativas**: nos modelos `groq/compound*`, `web_search()`, `web_fetch()` e
  `code_execution()` ligam as tools embutidas (`compound_custom`); nos `openai/gpt-oss-*`,
  `web_search()` vira `browser_search` e `code_execution()` vira `code_interpreter`.
  O compound não aceita function tools do usuário na mesma chamada. Veja
  [Tools nativas](native-tools.md).
- **Structured output**: campos com default passam a constar em `required` no modo
  strict (o Groq recusava o schema), e o fallback para JSON Object mode dispara em
  mais mensagens de erro.

---

# Gemini

Provider `gemini`. Adapter sobre o SDK `google-genai`. Tem um único `Client`; o
async fica em `client.aio`.

```bash
pip install "jangada-ai[gemini]"
```

- **`provider=`**: `"gemini"`
- **Variável de ambiente**: `GEMINI_API_KEY` (ou `GOOGLE_API_KEY`)

```python
from jangada_ai import LLM
llm = LLM("gemini", "gemini-2.5-flash")
```

## O que faz

- **Texto** e **streaming** (`generate_content` / `generate_content_stream`).
- **Structured output** (`parse`): `config.response_schema=Modelo` +
  `response_mime_type="application/json"` → `resp.parsed`.
- **Vision** (`images=`): imagens viram `types.Part.from_bytes`.
- **Documentos** (`files=`): extração de texto local.
- **Detecção de objetos** (`detect_objects`): **o mais preciso** — o formato de
  bounding box 0–1000 é nativo do treino do Gemini.
- **Transcrição de áudio** (`transcribe`): multimodal — o áudio entra como
  `Part.from_bytes` junto de uma instrução; **não** é endpoint dedicado.

## Estrutura e quirks

- **Mensagens**: o papel `system` vira `system_instruction` no
  `GenerateContentConfig` (não é uma mensagem comum); `assistant` vira `model`.
- **Parâmetros canônicos → config**: `max_tokens`→`max_output_tokens`,
  `stop`→`stop_sequences`; `temperature`/`top_p`/`top_k`/`seed` passam direto.
  É o único provider com `top_k`.
- **Perfil de modelo** (`profiles.py`): `gemini-3.x` descarta sampling
  (`temperature`/`top_p`/`top_k`). Veja [Parâmetros e perfis](parameters.md).
- **Function calling multi-turno no 3.x — *thought signatures* (resolvido pela
  lib)**: o Gemini 3.x anexa um `thought_signature` opaco a cada function call e
  o exige de volta, inalterado, ao reenviar o histórico. A jangada preserva isso
  automaticamente — o `thought_signature` viaja no campo opaco
  `metadata: dict` de `ToolCall`/`ToolCallPart` (dados do provider que a lib só
  guarda, não interpreta), e o adapter o reanexa na `Part` ao remontar o
  histórico. Ou seja, `tools=`, `Agent` e `MCPClient` funcionam em multi-turno
  sem você tocar em nada. `gemini-2.5` não usa esse campo. (corrigido em 1.3.1)
- **Resposta** (`Completion`): `usage` vem de `usage_metadata`
  (`prompt_token_count`/`candidates_token_count`).

## Thinking (raciocínio) — você passa só `thinking_budget`

O Gemini tem duas convenções incompatíveis: o **2.5** só aceita `thinking_budget`
(em tokens) e o **3.x** só aceita `thinking_level` (`LOW`/`HIGH`) — misturar dá
HTTP 400. A jangada resolve por você: passe **`thinking_budget`** (ou
`thinking_level`) e a lib adapta ao modelo, empacotando no `thinking_config`
nativo por baixo dos panos.

```python
# Funciona igual nas duas versões — você não muda nada:
LLM("gemini", "gemini-2.5-flash").complete("...", params={"thinking_budget": 1024})
LLM("gemini", "gemini-3-pro").complete("...",  params={"thinking_budget": 1024})
# no 2.5 vira thinking_config(thinking_budget=1024);
# no 3.x o perfil converte para thinking_config(thinking_level="HIGH").
```

- `thinking_budget`: `0` desliga (quando o modelo permite), `-1` é automático.
- No 3.x o budget é aproximado para um nível válido (`LOW` se ≤0, senão `HIGH`);
  no 2.5 um `thinking_level` é convertido para budget. Um `thinking_config` pronto
  é respeitado como veio.
- **Níveis aceitos**: `thinking_level` reconhece `MINIMAL`/`LOW`/`MEDIUM`/`HIGH`.
  Como **entrada no 2.5** os quatro são convertidos para um `thinking_budget`
  (`0`/`1024`/`8192`/`24576`). Já **direto no 3.x**, o Gemini só aceita `LOW` e
  `HIGH` de forma universal (`MINIMAL` é só Flash/Lite e `MEDIUM` só no 3.0 Flash;
  passá-los como nível em outros 3.x dá HTTP 400) — por isso a conversão
  budget→level da lib usa apenas `LOW`/`HIGH`.

## Por que é o "canivete suíço" aqui

É o único que cobre vision, detecção e áudio **sem endpoints separados** —
tudo via `generateContent`. Veja [Matriz de capacidades](capabilities.md).

## O que mudou na 1.9.0

- **`ToolCall.id`** é o `id` que a API devolve (ou `"nome#i"` quando não vem) — não
  mais o nome da função. Duas chamadas paralelas à mesma função não colidem. O
  nome real continua sendo usado no `function_response`, e históricos antigos
  (id igual ao nome) seguem funcionando.
- **Parâmetro de tool chamado `title`** não some mais do schema (o limpador de
  schema removia a chave `title` também dentro de `properties`).
- **Prompt bloqueado por safety** levanta `BadRequestError` com o motivo
  (`prompt_feedback.block_reason`), sem retry nem fallback.
- **Usage**: `output_tokens` inclui os tokens de thinking (`reasoning_tokens` mostra
  quantos) e `input_tokens` inclui os tokens de resultado de tools nativas — ambos
  são cobrados; antes o custo saía subestimado.
- **Tools nativas**: Google Search, URL context, code execution, file search,
  Google Maps e computer use (veja [Tools nativas](native-tools.md)). Para a
  Interactions API e os agentes (Deep Research), veja [Interactions](interactions.md).
- `transcribe` honra `language=`; `parse` com resposta vazia levanta erro em vez de
  devolver `parsed=None`; system em lista de partes tem o texto extraído.

---

# Anthropic (Claude)

Provider `anthropic`. Adapter sobre o SDK `anthropic`.

```bash
pip install "jangada-ai[anthropic]"
```

- **`provider=`**: `"anthropic"`
- **Variável de ambiente**: `ANTHROPIC_API_KEY`

```python
from jangada_ai import LLM
llm = LLM("anthropic", "claude-opus-4-8")
```

## O que faz

- **Texto** e **streaming**.
- **Structured output** (`parse`): por **tool-forcing** — define uma ferramenta
  com o schema e fixa `tool_choice`, depois valida o `tool_use.input` contra o
  modelo Pydantic. (Claude não tem um `response_format` como a OpenAI.)
- **Vision** (`images=`): imagens viram bloco `image` com `source` base64.
- **Documentos** (`files=`): extração de texto local.

## O que NÃO faz

- **Transcrição de áudio**: a API do Claude **não aceita áudio** — `transcribe()`
  levanta `UnsupportedError`. Use OpenAI, Groq ou Gemini para áudio (e, se
  quiser, mantenha o Claude como provider de texto com fallback de áudio em
  outro). Veja [Transcrição de áudio](audio.md).
- **Detecção de objetos**: funciona mecanicamente (vision + structured), mas a
  precisão das coordenadas é menor que a do Gemini.

## Estrutura e quirks

- **Parâmetros**: `temperature`, `max_tokens`, `top_p`, `top_k`, `stop`
  (→ `stop_sequences`). **Não** tem `seed` (é descartado).
- **`max_tokens` é obrigatório** na API do Claude — a jangada já usa um default
  alto (`8192`) quando você não informa.
- **System**: vai no campo `system` da requisição (não como mensagem).
- **Resposta** (`Completion`): `usage` de `input_tokens`/`output_tokens` nativos;
  `raw` com o objeto da SDK.
- **Erros**: 529 (overloaded) vira `OverloadedError` (subtipo de `ServerError`),
  elegível a retry/fallback. Veja [Erros](errors.md).

Relacionado: [Matriz de capacidades](capabilities.md),
[Structured output](structured-output.md).

## O que mudou na 1.9.0

- **Compatível com `anthropic>=1.0`.** O SDK 1.0 removeu `temperature`, `top_p` e
  `top_k` da assinatura de `messages.create()` — passá-los dava `TypeError`. A lib
  agora os envia pelo corpo (`extra_body`), então `LLM("anthropic", ..., temperature=0.2)`
  funciona em qualquer versão do SDK.
- **Extended thinking com tools em vários turnos**: os blocos `thinking`/
  `redacted_thinking` (com assinatura) são preservados e reenviados antes do
  `tool_use`, como a API exige. No `parse`, o thinking é desligado (o
  `tool_choice` forçado é incompatível com ele).
- **Tools nativas**: `web_search`, `web_fetch` e `code_execution` (veja
  [Tools nativas](native-tools.md)); os blocos com `encrypted_content` voltam intactos
  no histórico e `pause_turn` é continuado automaticamente.
- **Usage com cache**: `input_tokens` inclui os tokens de cache; `cache_read_tokens`
  e `cache_write_tokens` aparecem quando há prompt caching, e o custo usa o preço
  de cache.
- Recusa sem `tool_use` no `parse` levanta `OutputValidationError` (entra no
  failover); resposta sem conteúdo vira `ServerError`.

---

# Ollama

Provider `ollama`. Adapter sobre o SDK oficial `ollama`, falando com a **API
nativa** do [Ollama](https://docs.ollama.com) (`/api/chat`, `/api/embed`).
Roda modelos **locais** (sem chave, sem custo por token) e também o **Ollama
Cloud**.

```bash
pip install "jangada-ai[ollama]"     # SDK ollama>=0.6
ollama serve                         # servidor local em http://localhost:11434
ollama pull llama3.2
```

- **`provider=`**: `"ollama"`
- **Variável de ambiente**: `OLLAMA_API_KEY` (**opcional**; só para o Cloud e
  para web search/fetch)
- **Host**: `host=` no construtor > env `OLLAMA_HOST` > `http://localhost:11434`

```python
from jangada_ai import LLM

llm = LLM("ollama", "llama3.2")          # funciona sem chave nenhuma
print(llm.complete("Diga olá em português.").text)
```

> **Por que a API nativa e não a compatível com OpenAI (`/v1`)?** Só a nativa
> expõe `num_ctx` (tamanho do contexto), `keep_alive`, `format` com JSON Schema
> completo e `think`. Pela `/v1` não dá para aumentar o contexto, e o Ollama
> corta o prompt em silêncio quando ele passa do limite.

## Local vs Cloud

```python
# local (padrão)
LLM("ollama", "qwen3")

# outro servidor da rede
LLM("ollama", "qwen3", host="http://192.168.0.10:11434")

# Ollama Cloud: host explícito + OLLAMA_API_KEY no ambiente (ou api_key=)
LLM("ollama", "gpt-oss:120b", host="https://ollama.com")
```

Ter `OLLAMA_API_KEY` no ambiente **não** troca o host sozinho: para usar o
Cloud, passe `host="https://ollama.com"`. Com chave, o adapter envia
`Authorization: Bearer <chave>`.

## Opções nativas via `extra=`

Os params canônicos (`temperature`, `top_p`, `top_k`, `seed`, `stop`) vão em
`options`, e `max_tokens` vira `options.num_predict`. O resto vai por `extra=`:

| Chave | Onde vai | Para quê |
|---|---|---|
| `num_ctx` | `options` | Tamanho da janela de contexto. **A jangada não define default**: o valor padrão depende da versão do servidor e da VRAM. Para agentes/RAG, passe algo como `8192`–`32768`. |
| `keep_alive` | topo | Quanto tempo o modelo fica carregado (ex.: `"10m"`, `-1`) |
| `think` | topo | Thinking: `True`/`False` ou `"low"`/`"medium"`/`"high"` (gpt-oss só aceita níveis) |
| `format` | topo | `"json"` ou um JSON Schema (o `parse` já preenche) |
| `logprobs`, `top_logprobs` | topo | Log-probabilidades |
| qualquer outra (`min_p`, `repeat_penalty`…) | `options` | Opções do modelo |

```python
llm = LLM("ollama", "llama3.2", temperature=0.2,
          extra={"num_ctx": 8192, "keep_alive": "10m"})
```

## O que faz

- **Texto** (`complete`/`acomplete`) e **streaming** (`stream`/`astream`). No
  stream, o usage e o `finish_reason` chegam no último chunk e ficam em
  `llm.provider.last_stream`.
- **Structured output** (`parse`/`aparse`): envia o JSON Schema do modelo
  Pydantic em `format`. Pela doc do Ollama, o Cloud **não** suporta `format`
  hoje.

  ```python
  from pydantic import BaseModel

  class Pais(BaseModel):
      nome: str
      capital: str

  print(llm.parse("Fale do Brasil.", Pais).parsed)
  ```

- **Tools / function calling**: com schema aninhado completo (parâmetros
  Pydantic, `anyOf`). A API não devolve id por chamada, então a jangada gera
  `"nome#i"`. O resultado volta como `role="tool"` com `tool_name`.
  `tool_choice="none"` não envia as tools; os demais valores são ignorados (a
  API não tem esse parâmetro).
- **Vision** (`images=`): as imagens vão em base64 na mensagem (use um modelo com
  visão, ex.: `llama3.2-vision`, `qwen2.5vl`, `gemma3`).
- **Thinking**: com `extra={"think": True}`, o raciocínio vai para
  `comp.raw.message.thinking` e **não** entra em `comp.text`. Em respostas com
  tool calls, ele é guardado e reenviado no histórico.
- **Embeddings** (`embed`/`aembed`): via `/api/embed`, em lotes de 64, com
  `dimensions=` e `truncate=` opcionais.

  ```python
  emb = LLM("ollama", "embeddinggemma")
  vetor = emb.embed("jangada é uma embarcação")
  ```

- **Documentos** (`files=`): extração de texto local (comum a todos os providers).

## Web search e web fetch

O Ollama tem busca e leitura de páginas na web pela API do Cloud (conta
gratuita, exige `OLLAMA_API_KEY`). Há dois jeitos de usar:

**Como tool nativa**: `web_search()`/`web_fetch()` em `tools=`. Como o Ollama não
executa essas tools no servidor, **o adapter executa** quando o modelo as chama
(até 5 rodadas, somando usage) e devolve a resposta final com `server_tool_calls`
e `citations`, igual aos outros providers. Suas function tools continuam
funcionando junto: se o modelo chamar uma delas, a resposta volta com
`tool_calls` normalmente. `stream` e `parse` com essas tools não são suportados.

```python
from jangada_ai import LLM, web_search

llm = LLM("ollama", "qwen3", extra={"num_ctx": 32768})
comp = llm.complete("Quais são as novidades do Python 3.14?", tools=[web_search()])
print(comp.text)
for c in comp.citations:
    print("-", c.title, c.url)
```

**Direto no provider**: devolve dados, sem passar pelo modelo.

```python
resultados = llm.provider.web_search("jangada nordeste", max_results=3)
# [{"title": ..., "url": ..., "content": ...}, ...]
pagina = llm.provider.web_fetch("https://ollama.com")
# {"title": ..., "url": ..., "content": ..., "links": [...]}
# async: await llm.provider.aweb_search(...) / aweb_fetch(...)
```

Veja [Tools nativas](native-tools.md).

## Erros (com dica)

- **Modelo não baixado** (404) → `NotFoundError` com a dica `ollama pull <modelo>`.
  Entra no failover padrão, então um `LLM(...).with_fallback(...)` cai para o
  próximo modelo.
- **Servidor fora do ar** → `APIConnectionError` com a dica `ollama serve`.
- **Erro no meio do stream** (vem como objeto `{"error": ...}` no NDJSON) →
  `ServerError`.

## O que NÃO suporta (levanta erro claro)

- **MCP server-side** (`mcp_servers=`): `UnsupportedError`. MCP **client-side**
  (`MCPClient`/`run_agent`) funciona normalmente.
- **Transcrição de áudio** e **OCR**: `UnsupportedError`.
- **Web search/fetch sem `OLLAMA_API_KEY`**: `UnsupportedError`.

## Custo

Modelo local não tem custo por token, e o Cloud é cobrado por assinatura. Por
isso `comp.cost` vem `None` (não há regra de preço para o Ollama). Os tokens
continuam em `comp.usage` (`input_tokens` = `prompt_eval_count`, `output_tokens`
= `eval_count`).

Relacionado: [Tools nativas](native-tools.md), [Providers e chaves](providers.md),
[Matriz de capacidades](capabilities.md), [Parâmetros](parameters.md).

---

# Parâmetros de geração e perfis por modelo

A jangada aceita **nomes canônicos** de parâmetros e cada adapter os traduz para
o nome nativo do SDK, descartando os não suportados.

## Parâmetros canônicos

| Canônico      | OpenAI/Groq            | Anthropic        | Gemini                |
|---------------|------------------------|------------------|-----------------------|
| `temperature` | `temperature`          | `temperature`    | `temperature`         |
| `max_tokens`  | `max_tokens`           | `max_tokens`     | `max_output_tokens`   |
| `top_p`       | `top_p`                | `top_p`          | `top_p`               |
| `top_k`       | *(descartado)*         | `top_k`          | `top_k`               |
| `stop`        | `stop`                 | `stop_sequences` | `stop_sequences`      |
| `seed`        | `seed`                 | *(descartado)*   | `seed`                |

Por padrão, `LLM` usa `max_tokens=8192` em todos os providers. Esse valor pode
ser alterado no construtor ou sobrescrito em uma chamada específica:

```python
llm = LLM("anthropic", "claude-opus-4-8")  # max_tokens=8192

# override por chamada
llm.complete("...", params={"temperature": 0.9, "max_tokens": 16384})

# clone com novos defaults
criativo = llm.with_params(temperature=1.0)
```

Parâmetros específicos de um SDK que não têm nome canônico vão via `extra=`.

## Perfis automáticos (quirks por modelo)

Modelos do mesmo provider às vezes têm contratos diferentes. A jangada normaliza
isso em `profiles.py`, **por modelo**, sem você precisar saber:

- `gpt-5` rejeita `temperature` (HTTP 400) e exige `max_completion_tokens`.
- `gemini-3.x` descarta `temperature`/`top_p`/`top_k`.
- **Thinking do Gemini**: você passa só `thinking_budget` (ou `thinking_level`) e
  a lib adapta — no 2.5 vira `thinking_budget`, no 3.x vira `thinking_level` —,
  empacotando no `thinking_config` nativo. Ver [Gemini](llm-gemini.md).

`thinking_budget`/`thinking_level` **não são** kwargs do construtor: um kwarg solto
cairia em `client_kwargs` (vai para o `genai.Client`, lugar errado). Passe por
`extra=` no construtor **ou** por `params=` na chamada:

```python
# no construtor (vale para todas as chamadas deste LLM)
llm = LLM("gemini", "gemini-3.5", extra={"thinking_level": "LOW"})

# por chamada (sobrescreve só nesta)
llm.complete("...", params={"thinking_budget": 1024})
```

Não misture as duas convenções no mesmo valor (ex.: `thinking_level=500` é erro —
`thinking_level` é enum `LOW`/`HIGH`; `500` seria um `thinking_budget`). É
justamente o que a lib evita: escolha uma e ela traduz para o que o modelo aceita.

Ordem aplicada no adapter: `_translate()` (canônico → nativo) →
`apply_profile()` (quirks de modelo) → empacota `thinking_config`. Ao suportar um
modelo novo com contrato diferente, adicione uma regra em `profiles.py` em vez de
espalhar `if`s.

Veja [Providers](providers.md) e [Estendendo](extending.md).

## O que mudou na 1.9.0

- **`profile_model=`**: quando o `model` não é o nome real do modelo (ex.: o nome do
  deployment na Azure), diga qual é o modelo base para a lib aplicar as regras
  certas: `LLM("azure", "meu-deploy", profile_model="gpt-5")` tira `temperature` e
  usa `max_completion_tokens`, como no gpt-5. Não é enviado ao SDK.
- **Aliases de perfil**: `vertex` herda as regras do `gemini` e `azure` as do
  `openai`. Para um provider seu: `jangada_ai.profiles.register_alias("meu", "openai")`.
- **Série o (o1/o3/o4)**: `max_tokens` vira `max_completion_tokens` e sampling é
  removido, como no gpt-5.
- **`thinking_budget` no Gemini 3.x** é graduado: até 1024 → `LOW`, até 8192 →
  `MEDIUM` (só nos modelos Flash), acima → `HIGH`. No `gemini-2.5-pro`,
  `thinking_level="MINIMAL"` vira 128 (o mínimo aceito), não 0.
- **Remover um parâmetro** de um `LLM` derivado: `llm.with_params(temperature=REMOVE)`
  (`from jangada_ai import REMOVE`); `None` continua sendo ignorado.
- `max_retries` negativo levanta `ValueError` na criação do `LLM`.

## Exemplo

[`examples/model_profiles_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/model_profiles_example.py) — script executável.

---

# Structured output (Pydantic)

Uma só chamada `parse()` devolve uma instância Pydantic validada, independente
de como cada provider implementa isso por baixo.

```python
from pydantic import BaseModel
from jangada_ai import LLM

class Pessoa(BaseModel):
    nome: str
    idade: int

llm = LLM("openai", "gpt-4o-mini")
comp = llm.parse("Extraia: João tem 30 anos.", Pessoa)
print(comp.parsed.nome, comp.parsed.idade)   # João 30
```

- `comp.parsed` → a instância Pydantic. **Confiável**: se algum SDK devolver
  `parsed=None` com JSON válido em `.text`, a jangada valida o texto pelo schema
  automaticamente (você não precisa fazer `model_validate_json` à mão).
- `comp.text` → o JSON bruto retornado.
- `comp.usage` / `comp.cost` → tokens e custo estimado.

> 🔁 **JSON fora do schema → failover.** Se a saída não casar com o modelo, a
> jangada levanta `errors.OutputValidationError` e **tenta o próximo modelo** do
> `with_fallback` (não repete o mesmo — daria o mesmo JSON). Assim o fallback
> cobre tanto erro de API quanto resposta malformada.

> `max_tokens` tem default de **8192**. Em listas especialmente grandes a resposta pode ser
> cortada; nesse caso a jangada levanta `errors.TruncatedError` ("aumente
> max_tokens") **antes** de validar — em vez de um erro confuso de JSON cortado do
> Pydantic. A correção é subir `max_tokens` (ver [Parâmetros](parameters.md)).

## Async

```python
comp = await llm.aparse("Extraia: ...", Pessoa)
```

## Como cada provider resolve

| Provider  | Mecanismo                                                |
|-----------|----------------------------------------------------------|
| OpenAI    | `chat.completions.parse(response_format=Modelo)`         |
| Groq      | `json_schema` quando o modelo suporta; senão **JSON Object mode** |
| Gemini    | `config.response_schema=Modelo` → `resp.parsed`          |
| Anthropic | tool-forcing (`tool_choice` fixo) → valida `tool_use`    |

Você não precisa saber qual é qual — `parse()`/`aparse()` cuidam disso. Use
Pydantic v2 (`model_json_schema()`, `model_validate`).

> **Groq — funciona em qualquer modelo.** Só alguns modelos do Groq aceitam
> `json_schema` (ex.: `openai/gpt-oss-*`, `llama-4-scout`). Para os demais (ex.:
> `llama-3.3-70b-versatile`), a jangada **cai automaticamente para JSON Object
> mode**: injeta o schema na instrução, pede um objeto JSON e valida com Pydantic.
> Você chama `parse()` igual — sem saber o que o modelo suporta.

Funciona junto com [Vision](vision.md) e [Documentos](documents.md): passe
`images=` ou `files=` na mesma chamada `parse()`.

## Exemplo

[`examples/structured_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/structured_example.py) — script executável.

---

# Tools (function calling)

O modelo pode pedir para chamar **ferramentas** (funções). A API é de **baixo
nível**: o `complete()` devolve as chamadas pedidas em `Completion.tool_calls`,
**você executa** e reenvia o resultado. Suportado em **OpenAI, Groq, Anthropic
e Gemini** — a mesma interface nos quatro.

```python
from jangada_ai import LLM, Message

def get_weather(city: str, units: str = "metric") -> str:
    """Retorna o clima atual de uma cidade."""
    return "25°C, ensolarado"

llm = LLM("openai", "gpt-4o-mini")

# 1) o modelo decide chamar a ferramenta
comp = llm.complete("Como está o tempo em Recife?", tools=[get_weather])

# 2) você executa cada chamada e monta os resultados
results = []
for call in comp.tool_calls:        # call.name, call.args (dict)
    saida = get_weather(**call.args)
    results.append(call.result(saida))

# 3) reenvia: histórico = pergunta + resposta-com-tool-calls + resultados
comp2 = llm.complete(
    "Como está o tempo em Recife?",
    history=[comp.assistant_message(), Message.tool_results(*results)],
    tools=[get_weather],
)
print(comp2.text)
```

## Definindo ferramentas

`tools=[...]` aceita:

- **função Python** — o schema sai da assinatura (type hints) + docstring;
- **modelo Pydantic** — vira o schema dos argumentos;
- **dict** `{"name", "description", "parameters"}` (JSON Schema) já pronto;
- um **`Tool`** (via `to_tool(...)`).

`tool_choice` controla a escolha: `"auto"` (padrão), `"none"`, `"required"`, ou
o **nome** de uma ferramenta para forçá-la.

## Peças

- `Completion.tool_calls`: lista de `ToolCall(id, name, args)`. Cada call também
  carrega um `metadata: dict` opaco — dados específicos do provider que a lib
  apenas preserva (não interpreta) e propaga de volta no histórico. É o que
  mantém, por exemplo, o `thought_signature` do Gemini 3.x vivo entre rodadas;
  você normalmente não precisa tocar nele.
- `comp.assistant_message()`: reconstrói a mensagem do assistant (texto + tool
  calls, incluindo o `metadata` de cada call) para o histórico.
- `call.result(saida)`: cria o `ToolResultPart` correspondente.
- `Message.tool_results(*parts)`: empacota os resultados numa mensagem.

## Ferramentas pré-prontas

A jangada traz tools prontas em `jangada_ai.prebuilt`:

```python
from jangada_ai.prebuilt import tavily_search   # busca na web (Tavily)

llm.complete("Qual a cotação do dólar hoje?", tools=[tavily_search])
# execute: tavily_search(**call.args)  (precisa de TAVILY_API_KEY no ambiente)
```

**Sem dependência e sem chave:**

| Tool | O que faz |
|------|-----------|
| `calculator` | avalia expressões aritméticas (seguro, via `ast`) |
| `current_datetime` | data/hora atuais (fuso IANA) |
| `fetch_url` | baixa uma página e devolve o texto legível (bloqueia rede interna por padrão) |
| `wikipedia_search` | resumo da Wikipedia (sem chave) |
| `http_request` | requisição HTTP genérica (GET/POST/...; bloqueia rede interna por padrão) |

**Com chave (lê do ambiente, ou passe `api_key=`):**

| Tool | Chave |
|------|-------|
| `tavily_search` | `TAVILY_API_KEY` (ou `tavily_tool(api_key=...)`) |
| `brave_search` | `BRAVE_API_KEY` |
| `openweather` | `OPENWEATHER_API_KEY` |

> Parâmetros **keyword-only** (após `*`, como `api_key`/`timeout`) são config de
> runtime e **não** aparecem no schema que o modelo vê.

### Conectores Brasil (`jangada_ai.prebuilt.br`)

Serviços brasileiros muito usados, **sem dependência e sem chave**:

```python
from jangada_ai.prebuilt import consultar_cep, validar_cpf

llm.complete("O CEP 01310-100 é de qual cidade?", tools=[consultar_cep])
```

| Tool | O que faz |
|------|-----------|
| `validar_cpf` / `validar_cnpj` / `validar_documento` | valida dígitos verificadores (texto pro modelo); `validar_documento` detecta o tipo pelo nº de dígitos |
| `cpf_valido` / `cnpj_valido` | mesma validação, mas devolve `bool` (uso de biblioteca, não como tool) |
| `formatar_cpf` / `formatar_cnpj` | formata dígitos como `000.000.000-00` / `00.000.000/0000-00` |
| `consultar_cep` | endereço + coordenadas de um CEP ([BrasilAPI](https://brasilapi.com.br)) |
| `consultar_cnpj` | cadastro na Receita (razão social, situação, CNAE, endereço, sócios) — diferente de `validar_cnpj`, que só confere os dígitos |
| `consultar_banco` | banco por código de compensação ou por nome |
| `feriados_nacionais` | feriados nacionais de um ano |
| `consultar_ddd` | UF e cidades atendidas por um DDD |
| `taxas_juros` | SELIC/CDI/IPCA e demais índices oficiais |

As consultas de rede (`consultar_*`, `feriados_nacionais`, `taxas_juros`) usam a
[BrasilAPI](https://brasilapi.com.br) (pública, sem autenticação); a validação de
CPF/CNPJ é local (algoritmo puro, sem rede).

As tools que chamam API externa tratam **todos os casos de resposta**: rate
limit (429, respeitando `Retry-After`), auth (401/403), 404, 5xx, timeout/
conexão e corpo não-JSON. Erros transitórios (429/5xx/timeout) têm **retry leve
com backoff**; ao esgotar, a tool **devolve uma mensagem de erro como texto**
(em vez de levantar exceção), para o modelo decidir o que fazer.

Veja também [Structured output](structured-output.md) (que no Anthropic já usa
tool-forcing por baixo) e [Observabilidade](observability.md) (as tool calls
aparecem no trace).

## O que mudou na 1.9.0

- **Tipos de parâmetro.** `int | None` (sintaxe do Python 3.10+) vira o tipo
  opcional correto no schema também no Python 3.10–3.13; `Union` de vários tipos
  vira `anyOf`; um parâmetro anotado com um `BaseModel` do Pydantic vira o schema
  do modelo, e `jangada_ai.coerce_args(fn, args)` converte o dict recebido na
  instância (o `Agent` já faz isso sozinho). Anotações que não resolvem (forward
  ref quebrada) não derrubam mais a função inteira.
- **Argumentos com JSON inválido** não viram `{}` em silêncio: o `ToolCall` traz
  `metadata["raw_arguments"]` e `metadata["args_error"] = True`.
- **Tools nativas** (web search, code execution…) entram na mesma lista `tools=`
  — veja [Tools nativas](native-tools.md).

### Ferramentas prontas mais seguras

- **`fetch_url` / `http_request` bloqueiam rede interna por padrão.** Só `http`/
  `https`; o host é resolvido e loopback, rede privada, link-local (inclui o
  metadata da nuvem, `169.254.169.254`), reservado e multicast são recusados —
  também em cada redirect (máx. 5). O corpo é lido até 5 MB. Isso impede que um
  prompt injection numa página faça o agente ler seu `.env` ou a rede da empresa.
  Para chamar um serviço interno de propósito: `allow_private=True` na chamada ou
  a env `JANGADA_HTTP_ALLOW_PRIVATE=1`.
- **Erros de rede viram mensagem para o modelo** (URL malformada, conexão
  resetada, resposta incompleta), em vez de exceção que derruba o loop.
- **`fetch_url`** respeita o charset do servidor e decodifica entidades HTML.
- **`calculator`** limita expoente (±1000) e tamanho dos números (10 mil dígitos) —
  `9**9**9` não trava mais o processo.
- **CNPJ alfanumérico.** `validar_cnpj`, `formatar_cnpj`, `validar_documento` e
  `consultar_cnpj` aceitam o formato novo da Receita (letras nas 12 primeiras
  posições, dígitos verificadores numéricos), ex.: `12.ABC.345/01DE-35`.

## Exemplo

[`examples/tools_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/tools_example.py) — script executável.

---

# Tools nativas (server-side)

A lib trabalha com dois tipos de tool:

- **Function tools** ([Tools](tools.md)): você declara a função, o modelo pede
  para chamá-la e **o seu código** executa.
- **Tools nativas** (server-side / built-in): **o provider** executa do lado
  dele (busca na web, leitura de URL, execução de código, busca em arquivos,
  mapas, geração de imagem). Você só liga a tool e recebe a resposta pronta, com
  as fontes.

A jangada tem **uma API só** para as tools nativas de todos os providers. Você
escreve `web_search()` e o adapter traduz para `google_search` no Gemini,
`web_search_20250305` na Anthropic, `{"type": "web_search"}` na Responses API da
OpenAI, `browser_search` no Groq, plugin `web` no OpenRouter, `nova_grounding` no
Bedrock, e assim por diante.

```python
from jangada_ai import LLM, web_search

llm = LLM("gemini", "gemini-3.8-flash")
comp = llm.complete("Qual foi a última decisão do Copom sobre a Selic?",
                    tools=[web_search()])
print(comp.text)
for c in comp.citations:          # fontes normalizadas, iguais em todo provider
    print("-", c.title, c.url)
```

Tools nativas e function tools **se misturam na mesma lista**, nos providers
que permitem:

```python
def cotacao_interna(moeda: str) -> str:
    "Cotação interna da empresa para uma moeda."
    return "5,10" if moeda.upper() == "USD" else "indisponível"

comp = llm.complete(
    "Compare o dólar de hoje (busque na web) com a nossa cotação interna.",
    tools=[web_search(), cotacao_interna],
)
```

Se o provider não suporta uma tool (ou uma opção que **restringe** o
comportamento, como `allowed_domains`), a chamada levanta `UnsupportedError`
**antes** de chegar ao SDK. A lib nunca ignora em silêncio uma restrição que
você pediu. Opções que são só dica (`max_uses`, `user_location` num provider
que não tem esse campo) são ignoradas com log em nível debug.

## API

Todos os construtores estão em `jangada_ai` (e em `jangada_ai.native_tools`) e
devolvem um `NativeTool`.

| Construtor | O que faz | Opções canônicas |
|---|---|---|
| `web_search()` | Busca na web | `max_uses`, `allowed_domains`, `blocked_domains`, `user_location={"city","region","country","timezone"}` |
| `web_fetch()` / `url_context()` | Lê o conteúdo de URLs citadas no prompt | `max_uses`, `allowed_domains`, `blocked_domains` |
| `code_execution()` | Executa código no sandbox do provider | `container` (reuso), `files` (ids de arquivo, OpenAI) |
| `file_search(stores)` | Busca em arquivos indexados no provider | `stores` (vector stores / file search stores / libraries), `top_k`, `filter` |
| `google_maps()` | Grounding com Google Maps | `lat`, `lng`, `enable_widget` |
| `computer_use()` | O modelo pede ações de tela; **seu código** executa | `environment`, `display_width`, `display_height` |
| `image_generation()` | Gera imagem como tool | opções nativas via `**opts` |

Todos aceitam também:

- **`**opts`**: qualquer opção desconhecida vai direto para o payload nativo do
  provider. Exemplo: `web_search(search_context_size="high")` na OpenAI.
- **`provider_overrides={"anthropic": {...}}`**: opções aplicadas só naquele
  provider. Útil quando o mesmo código roda com fallback entre providers.
- **Anthropic**: `version=` escolhe a versão da server tool. Os defaults são
  `web_search_20250305`, `web_fetch_20250910` e `code_execution_20250825`, que
  funcionam sem *programmatic tool calling*.

```python
from jangada_ai import web_search

busca = web_search(
    max_uses=3,
    blocked_domains=["exemplo-ruim.com"],
    provider_overrides={"anthropic": {"version": "web_search_20260209"}},
)
```

### `native_tool`: o objeto nativo, sem tradução

Para uma opção ou versão que a camada canônica ainda não cobre, passe o objeto
(ou dict) **do próprio SDK** com `native_tool(provider, spec)`. Ele vai para o
payload exatamente como você escreveu, só naquele provider. Em qualquer outro
provider levanta `UnsupportedError`.

Isso substitui a gambiarra de montar o payload nativo à mão ou chamar o SDK por
fora da jangada: você continua com retry, fallback, custo, observabilidade e o
`Completion` normalizado.

```python
from google.genai import types
from jangada_ai import LLM, native_tool

llm = LLM("gemini", "gemini-3.8-flash")
comp = llm.complete(
    "Notícias de hoje sobre energia solar no Nordeste",
    tools=[native_tool("gemini", types.Tool(
        google_search=types.GoogleSearch(exclude_domains=["exemplo.com"]),
    ))],
)

claude = LLM("anthropic", "claude-sonnet-5")
comp = claude.complete(
    "Resuma as notícias de hoje sobre o Copom",
    tools=[native_tool("anthropic", {
        "type": "web_search_20260318", "name": "web_search", "max_uses": 2,
    })],
)
```

## Matriz por provider

| Provider | `web_search` | `web_fetch` | `code_execution` | `file_search` | outras |
|---|---|---|---|---|---|
| **Gemini** | `google_search` | `url_context` | `code_execution` | `file_search` (stores = `file_search_store_names`) | `google_maps`, `computer_use` |
| **Vertex AI** | idem Gemini | idem | idem | idem | idem |
| **Anthropic** | `web_search_*` | `web_fetch_*` | `code_execution_*` | — | `computer_use` só via `native_tool` |
| **OpenAI / Azure** | `web_search` | — | `code_interpreter` | `file_search` (stores = `vector_store_ids`) | `image_generation`, `computer_use` |
| **Groq** `groq/compound*` | `web_search` embutida | `visit_website` | `code_interpreter` | — | — |
| **Groq** `openai/gpt-oss-*` | `browser_search` | — | `code_interpreter` | — | — |
| **OpenRouter** | plugin `web` | — | — | — | — |
| **DeepSeek** | — | — | — | — | nenhuma |
| **Mistral** | `web_search` (`premium=True` → `web_search_premium`) | — | `code_interpreter` | `document_library` (stores = `library_ids`) | `image_generation` |
| **Bedrock** (Amazon Nova) | `nova_grounding` (Nova Premier / Nova 2) | — | `nova_code_interpreter` (Nova 2) | — | — |
| **Ollama** | executada pelo adapter | executada pelo adapter | — | — | — |

"—" = `UnsupportedError`.

### Restrições que você precisa saber

- **Gemini**: misturar tools nativas com function tools liga
  `include_server_side_tool_invocations` (Gemini 3). `allowed_domains` não
  existe na busca do Gemini (`UnsupportedError`); `blocked_domains` vira
  `exclude_domains`. `container`/`files` do `code_execution` não existem no
  Gemini.
- **Vertex AI**: aceita as mesmas tools, mas **não** mistura tool nativa com
  function tool na mesma chamada (o campo não existe no Vertex) →
  `UnsupportedError`.
- **Anthropic**: `allowed_domains` e `blocked_domains` juntos dão erro (a API
  exige um ou outro). `stop_reason="pause_turn"` é continuado automaticamente
  (até 5 vezes, somando usage e custo). O `container` do code execution é
  reusado a partir do histórico.
- **OpenAI / Azure**: com qualquer tool nativa, a chamada vai pela **Responses
  API** (não chat.completions). `blocked_domains` não existe na busca da OpenAI.
- **Groq**: os modelos `groq/compound*` têm as tools embutidas e **não aceitam
  function tools do usuário** junto. Nos `openai/gpt-oss-*`, a busca não aceita
  filtro de domínio. Outros modelos do Groq não têm tools nativas.
- **OpenRouter**: `web_search()` vira o plugin `web`; `engine=` (ex.: `"exa"`,
  `"native"`) e os domínios são repassados ao plugin.
- **DeepSeek**: a API ignora tools built-in, então nem
  `native_tool("deepseek", ...)` é aceito.
- **Mistral**: as tools nativas só existem na **Conversations API**; com elas a
  chamada vai por `beta.conversations` em vez de `chat.complete`. Por padrão o
  histórico é reenviado a cada turno. Com `params={"store": True}` a conversa
  fica guardada no servidor e o turno seguinte usa `append`. Function tools
  funcionam junto; `stream` e `parse` com tools nativas não.
- **Bedrock**: só modelos **Amazon Nova** (perfis `us.*`) e a permissão IAM
  `bedrock:InvokeTool`. As server tools da Anthropic **não** existem no Bedrock.
- **Ollama**: a API do Ollama não executa busca no servidor. `web_search()` e
  `web_fetch()` viram function tools que **o adapter executa** (via Ollama
  Cloud, até 5 rodadas, somando usage) e devolve a resposta final com
  `server_tool_calls` e `citations`. Exige `OLLAMA_API_KEY`. `stream` e `parse`
  com essas tools não são suportados. Veja [Ollama](llm-ollama.md).
- **`parse()` com tools nativas**: só no Gemini (structured output + built-in
  tools). Nos outros providers levanta `UnsupportedError`.
- **`computer_use`**: a lib expõe o pedido de ação em `server_tool_calls`, mas
  **não executa** o loop de tela. Executar a ação e devolver o screenshot é
  com o seu código.
- **Cache**: chamadas com tools (nativas ou não) não entram no cache.

## O que volta no `Completion`

- **`comp.text`**: a resposta final (só texto; código executado não entra aqui).
- **`comp.citations`**: lista de
  `Citation(url, title, cited_text, start, end, source)`. `start`/`end` são
  offsets em `comp.text`, quando o provider informa.
- **`comp.server_tool_calls`**: lista de
  `ServerToolCall(type, input, output, id)`, o que o provider executou (queries de busca, código + saída, URLs
  lidas...). É informativo: a lib não executa nada.
- **`comp.usage["server_tool_requests"]`**: contagem por tool, ex.:
  `{"web_search": 2}`. É o que o provider cobra por chamada.
- **`comp.metadata`**: dados opacos do provider que precisam voltar no
  histórico (ver multi-turn abaixo).

```python
comp = llm.complete("Calcule a soma dos 50 primeiros primos.",
                    tools=[code_execution()])
for call in comp.server_tool_calls:
    print(call.type, call.input, "->", call.output)
```

> **Gemini + Google Search: exiba as sugestões de busca.** A política do Google
> exige mostrar as *Search Suggestions* junto da resposta com grounding. O HTML
> pronto vem em `comp.metadata["search_entry_point"]`.

## Multi-turn: `assistant_message()` preserva os blocos nativos

Algumas APIs exigem receber de volta, **intactos**, os blocos gerados pelas
tools nativas: `encrypted_content` da Anthropic, partes
`tool_call`/`tool_response` com `thought_signature` do Gemini, output items da
OpenAI. `comp.assistant_message()` copia esses blocos para
`Message.metadata`, e o adapter os reenvia exatamente como vieram. Basta usar o
histórico normalmente:

```python
from jangada_ai import LLM, Message, url_context, web_search

llm = LLM("anthropic", "claude-sonnet-5")
p1 = "Quais foram as principais notícias de tecnologia de hoje?"
comp = llm.complete(p1, tools=[web_search()])

hist = [Message(role="user", content=p1), comp.assistant_message()]
comp2 = llm.complete("Aprofunde a segunda notícia.", history=hist,
                     tools=[web_search(), url_context()])
```

Não monte a mensagem do assistant à mão a partir de `comp.text`: você perde os
blocos, e a Anthropic responde 400.

## Custo

Além dos tokens, várias tools nativas têm **taxa por chamada**. A jangada soma
essas taxas em `Completion.cost`, a partir de `usage["server_tool_requests"]`:

| Provider | Tool | Preço | Fonte |
|---|---|---|---|
| Anthropic | `web_search` | US$ 10 / 1.000 buscas | platform.claude.com (web search tool) |
| Anthropic | `web_fetch` | sem taxa (só tokens) | platform.claude.com (web fetch tool) |
| OpenAI / Azure | `web_search` | US$ 10 / 1.000 chamadas | developers.openai.com/api/docs/pricing |
| OpenAI / Azure | `file_search` | US$ 2,50 / 1.000 chamadas | developers.openai.com/api/docs/pricing |
| Gemini 3.x | `web_search` | US$ 14 / 1.000 queries | ai.google.dev/gemini-api/docs/pricing |
| Gemini 2.5 | `web_search` | US$ 35 / 1.000 prompts com grounding | ai.google.dev/gemini-api/docs/pricing |
| Gemini 3.x / 2.5 | `google_maps` | US$ 14 / 1.000 queries · US$ 25 / 1.000 prompts | ai.google.dev/gemini-api/docs/pricing |
| Mistral | `web_search` / `code_execution` | US$ 30 / 1.000 chamadas | mistral.ai/pricing/api |
| Mistral | `image_generation` | US$ 100 / 1.000 imagens | mistral.ai/pricing/api |
| Mistral | `file_search` (document library) | US$ 0,01 / chamada | mistral.ai/pricing/api |

Sem taxa confirmada (o custo dessas tools **não** entra em `cost`): Groq
compound/browser search, OpenRouter (varia por engine), Bedrock Nova grounding,
code interpreter da OpenAI (cobrado por sessão de container). As cotas grátis
dos providers (ex.: Gemini) **não** são descontadas.

Preços são aproximados e se atualizam sozinhos (ver [Custo](cost.md)). Registre
uma taxa própria com `jangada_ai.pricing.register_tool_fee(tool, usd_per_1k, ...)`.

## Com `Agent`

`Agent` aceita tools nativas misturadas com as suas funções. As nativas não são
executadas localmente: entram no `tool_trace` marcadas com `"server": True`.

```python
from jangada_ai import LLM, Agent, web_search

pesquisador = Agent(
    LLM("gemini", "gemini-3.8-flash"),
    role="Pesquisador",
    goal="Responder com fontes atuais",
    tools=[web_search(), cotacao_interna],
)
res = pesquisador.run("Como o dólar fechou hoje frente à nossa cotação interna?")
```

## Exemplos por provider

```python
from jangada_ai import LLM, code_execution, file_search, google_maps, web_search

# Gemini: busca + Maps com localização do usuário
LLM("gemini", "gemini-3.8-flash").complete(
    "Cafés abertos agora perto de mim", tools=[google_maps(lat=-8.05, lng=-34.9)])

# OpenAI: busca em vector store próprio
LLM("openai", "gpt-5").complete(
    "O que diz o contrato sobre multa?", tools=[file_search(["vs_123"], top_k=5)])

# Groq compound: busca + código embutidos (sem function tools do usuário)
LLM("groq", "groq/compound").complete(
    "Qual a população de Recife dividida pela de Olinda?",
    tools=[web_search(), code_execution()])

# OpenRouter: plugin web com engine escolhido
LLM("openrouter", "openai/gpt-5-mini").complete(
    "Novidades do Python 3.14", tools=[web_search(engine="exa", max_results=3)])

# Mistral: busca premium (notícias) pela Conversations API
LLM("mistral", "mistral-medium-latest").complete(
    "Manchetes de economia de hoje", tools=[web_search(premium=True)])

# Bedrock: grounding do Amazon Nova
LLM("bedrock", "us.amazon.nova-premier-v1:0").complete(
    "Quem é o atual presidente do Banco Central?", tools=[web_search()])
```

Relacionado: [Tools (function calling)](tools.md), [Ollama](llm-ollama.md),
[Gemini Interactions](interactions.md), [Custo](cost.md),
[Agentes](agents.md).

---

# MCP (Model Context Protocol)

Conecte servidores MCP às chamadas via `mcp_servers=[...]`. **Todos os providers
suportam MCP**, mas de formas diferentes — e a jangada usa o jeito nativo de cada
SDK.

## Dois modelos (importante)

- **Remoto (URL)** — `MCPServer(url=...)`: o **provider** conecta no servidor MCP
  e executa as tools (server-side). Você não roda nada. → **Anthropic, OpenAI, Groq**.
- **Client-side (sessão)** — você passa uma `ClientSession` do pacote `mcp`: o
  **SDK** chama as tools localmente (automatic function calling). → **Gemini**.

| Provider | Modelo | Como | Observação |
|----------|--------|------|------------|
| Anthropic | remoto (URL) | Messages API (`mcp_servers` + `MCPToolset`, header beta) | **beta** (`mcp-client-2025-11-20`) |
| OpenAI | remoto (URL) | **Responses API** (`tools=[{type:"mcp"}]`) | usa a Responses API, não `chat.completions` |
| Groq | remoto (URL) | Responses API (compatível com a OpenAI) | beta — a jangada usa o **client OpenAI no `base_url` do Groq** por baixo (precisa do pacote `openai` instalado) |
| Gemini | client-side (sessão) | `tools=[session]` (automatic function calling) | **só no async** (`acomplete`) — a sessão é assíncrona |

## Remoto (Anthropic / OpenAI / Groq)

```python
from jangada_ai import LLM, MCPServer

llm = LLM("anthropic", "claude-opus-4-8")   # ou ("openai", "gpt-4o"), ("groq", ...)
comp = llm.complete(
    "Liste as issues abertas do repositório.",
    mcp_servers=[MCPServer(
        url="https://mcp.exemplo.com/sse",
        name="github",
        authorization_token="TOKEN",     # opcional (OAuth/Bearer)
        allowed_tools=["list_issues"],   # opcional (restringe as tools)
    )],
)
print(comp.text)   # o provider já executou as tools do MCP
```

## Client-side (Gemini, async)

No Gemini, `MCPServer(url=...)` também funciona — mas só em **`acomplete`**: a
jangada abre um `MCPClient` próprio por baixo e entrega ao SDK como sessão
client-side (é o único jeito que o Gemini suporta). Precisa do extra `[mcp]`.

```python
from jangada_ai import LLM, MCPServer

llm = LLM("gemini", "gemini-2.5-flash")
comp = await llm.acomplete(
    "Liste as issues abertas.",
    mcp_servers=[MCPServer(url="https://mcp.exemplo.com/mcp/", name="github",
                           authorization_token="TOKEN")],
)
```

> No `complete()` (sync) isso continua levantando `UnsupportedError` — o Gemini
> não tem como abrir uma sessão assíncrona fora de `acomplete`. `allowed_tools`/
> `require_approval` do `MCPServer` também levantam `UnsupportedError` nesse
> caminho (em vez de serem ignorados em silêncio) — o SDK do Gemini lista e
> executa as tools sozinho, sem hook de filtro/aprovação; para restringir tools
> no Gemini, use o `MCPClient`/`run_agent` próprio da jangada (abaixo), que tem
> `allowed_tools=`.

A sessão MCP é assíncrona (montada manualmente), então use **`acomplete`**:

```python
from mcp import ClientSession
from mcp.client.stdio import stdio_client, StdioServerParameters
from jangada_ai import LLM

llm = LLM("gemini", "gemini-2.5-flash")
params = StdioServerParameters(command="npx", args=["-y", "@exemplo/mcp"])

async with stdio_client(params) as (read, write):
    async with ClientSession(read, write) as session:
        await session.initialize()
        comp = await llm.acomplete("Use a ferramenta X", mcp_servers=[session])
        print(comp.text)
```

> No Gemini, `complete()` (sync) com sessão levanta `UnsupportedError` pedindo
> `acomplete()`. Passar uma `MCPServer(url=...)` no Gemini também levanta — ele é
> client-side. E passar uma sessão no Anthropic/OpenAI/Groq levanta — eles são
> remotos por URL.

## Cliente MCP próprio + agente (portável, qualquer provider)

A jangada traz seu **próprio cliente MCP** (`MCPClient`) e um **loop de agente**
(`run_agent`) que conecta no servidor, lista as tools, e roda o ciclo
(modelo pede → executa → reenvia) **sozinho** — em **qualquer provider** (usa
`tools=`, suportado nos 4), independente do MCP nativo de cada SDK.

```bash
pip install "jangada-ai[mcp]"   # cliente MCP (pacote oficial `mcp`)
```

```python
from jangada_ai import LLM
from jangada_ai.mcp import MCPClient, run_agent

llm = LLM("openai", "gpt-4o-mini")   # ou anthropic/groq/gemini

async with MCPClient("https://meu-mcp/mcp/") as mcp:      # ou command=/args= (stdio)
    ans = await run_agent(llm, "Role uns dados", client=mcp)
    print(ans.text)
```

Por baixo: `await mcp.list_tools()` vira `tools=[...]`, e cada `tool_call` do
modelo é executado com `await mcp.call_tool(...)` e reenviado via
`Message.tool_results(...)` — o mesmo [tool calling](tools.md) de sempre, no
automático. Quer controle total? Use `MCPClient` + `tools=` na mão.

- **`isError` do resultado da tool** vira `is_error=True` no `tool_result` —
  o modelo sabe que a tool falhou (diferente de uma exceção de rede/protocolo,
  que já virava erro antes).
- **`list_tools`/`list_resources`/`list_prompts`** seguem o `nextCursor`
  sozinhos até esgotar as páginas — servidores com muitas tools não ficam
  incompletos. Param se um servidor com bug repetir o mesmo cursor, e têm um
  limite defensivo de 10.000 páginas para o caso (mais raro) de cursores que
  nunca se repetem, mas também nunca esgotam — batendo nesse limite SEM
  esgotar o cursor emite um `UserWarning` (a lista pode estar incompleta;
  nunca trunca em silêncio).
- **Limite de iterações**: se `run_agent` bater em `max_iterations` com
  `tool_calls` ainda pendentes, ele emite um `UserWarning` (`Completion`
  devolvido não é necessariamente a resposta final). No `Agent.run`/`arun`
  (abaixo), o mesmo caso também marca `AgentResult.stopped_by_limit = True`.
- **Erro de conexão** (`MCPClient.__aenter__`, ex.: token inválido, servidor
  fora do ar) vira um `APIConnectionError` da própria lib com a causa real
  (ex.: `HTTP 403`/`HTTP 400`), em vez do erro cru do transporte
  (`ExceptionGroup`/`CancelledError` do `anyio`) — mesmo quando a causa real só
  aparece ao FECHAR a conexão (não na abertura), caso real de servidor que
  recusa a conexão HTTP.

### Transporte, autenticação e timeout

```python
async with MCPClient(
    "https://meu-mcp/sse",     # URL terminando em /sse -> detecta SSE sozinho
    # transport="sse",         # ou force explicitamente ("sse" | "streamable-http")
    auth_token="TOKEN",        # vira header Authorization: Bearer TOKEN
    timeout=30,                # segundos, repassado ao transporte HTTP
) as mcp:
    ...
```

### Conexão de vida longa (`connect`/`aclose`/`reconnect`, `keep_alive`, `ping`)

Fora do `async with` — útil para abrir no startup da aplicação e fechar no
shutdown:

```python
mcp = MCPClient("https://meu-mcp/mcp/", auth_token="TOKEN")
await mcp.connect()          # idempotente: chamar de novo já conectado é no-op
...
await mcp.ping()             # health check (session/ping)
...
await mcp.reconnect()        # fecha e reabre (ex.: percebeu a conexão caída)
...
await mcp.aclose()           # no shutdown
```

Com `keep_alive=True`, o `MCPClient` conecta **sozinho** na 1ª chamada (sem
precisar de `connect()`/`async with`) e reconecta **uma vez**, sozinho, se uma
chamada falhar com algo que parece conexão caída — não repete em erro de
LÓGICA da tool (ex.: argumento inválido), o que dobraria um efeito colateral
de verdade:

```python
mcp = MCPClient("https://meu-mcp/mcp/", auth_token="TOKEN", keep_alive=True)
tools = await mcp.list_tools()   # conecta sozinho aqui
```

> Seguro para uso concorrente: se várias chamadas percebem a conexão caída ao
> mesmo tempo (ex.: um servidor concorrente atendendo requests em paralelo),
> só a primeira reconecta de verdade — as outras esperam e reaproveitam a
> sessão nova, em vez de disparar reconexões por cima umas das outras. Se a
> reconexão em si falhar (servidor fora do ar), as chamadas que esperavam
> reusam o MESMO erro por um cooldown curto, em vez de cada uma esperar seu
> próprio timeout de conexão do zero.

## Primitivos completos do MCP (no `MCPClient`)

Além de **tools**, o `MCPClient` cobre o resto do protocolo:

```python
async with MCPClient("https://meu-mcp/mcp/") as mcp:
    # Resources — dados/contexto que o server expõe
    recursos = await mcp.list_resources()
    texto    = await mcp.resource_text("file:///guia.md")

    # Prompts — templates reutilizáveis do server -> vira list[Message]
    msgs = await mcp.prompt_messages("revisar", {"texto": "..."})
    resp = await llm.acomplete(None, history=msgs)
```

E os **recursos de cliente** (o server chama o cliente de volta), configurados no
construtor:

```python
from jangada_ai import LLM

async with MCPClient(
    command="python", args=["server.py"],
    roots=["./workspace"],                       # escopo de filesystem (file://)
    sampling_llm=LLM("openai", "gpt-4o-mini"),   # o server pede geração ao SEU LLM
    elicitation_callback=meu_handler,            # o server pede input ao usuário
    logging_callback=meu_logger,                 # logs do server
) as mcp:
    ...
```

- **Roots**: o server pergunta quais diretórios pode usar; o cliente responde a lista.
- **Sampling**: o server pede uma geração de LLM (`sampling/createMessage`) e a
  jangada executa com o seu `LLM` — o server fica model-independent e **você**
  controla custo/permissões.
- **Elicitation / Logging**: você passa um callback (`async`) que o SDK chama.

| Primitivo | Métodos / config |
|---|---|
| Tools | `list_tools` / `call_tool` |
| Resources | `list_resources` / `read_resource` / `resource_text` |
| Prompts | `list_prompts` / `get_prompt` / `prompt_messages` |
| Roots | `roots=[...]` |
| Sampling | `sampling_llm=LLM(...)` |
| Elicitation | `elicitation_callback=...` |
| Logging | `logging_callback=...` / `set_logging_level(...)` |

## `run_agent`: histórico, callbacks, allowlist e retorno enriquecido

```python
from jangada_ai.message import Message

def veta_apagar(call):
    return call.name != "apagar_arquivo"   # False = veta a chamada

async with MCPClient("https://meu-mcp/mcp/") as mcp:
    ans = await run_agent(
        llm, "Liste e depois apague os temporários", client=mcp,
        history=[Message("user", "oi"), Message("assistant", "olá!")],  # turnos anteriores
        allowed_tools=["listar_arquivos", "apagar_arquivo"],  # restringe as tools visíveis
        on_tool_call=veta_apagar,        # (sync ou async) False = veta a chamada
        on_tool_result=lambda c, r: print(c.name, r.is_error),
    )
    print(ans.text, ans.iterations, ans.stopped_by_limit)
    print(ans.tool_trace)          # [{"call", "result", "is_error"}, ...] de TODAS as rodadas
    print(ans.usage_total, ans.cost_total, ans.cost_complete)   # agregados do loop inteiro
```

- `history=` injeta turnos anteriores antes da tarefa; `prompt=None` continua
  só do histórico (sem adicionar mensagem de usuário vazia).
- `allowed_tools=` filtra pelo nome antes do modelo ver as tools (também
  disponível em `mcp_tools(client, allowed_tools=[...])` direto).
- `tools=` pula o `list_tools()` (e o round-trip) quando você já listou antes
  — liste uma vez com `await mcp_tools(client, ...)` e reuse entre chamadas
  (ex.: um chatbot chamando `run_agent` por mensagem); `allowed_tools=`
  continua sendo aplicado por cima de `tools=` (filtra a lista já pronta),
  então dá pra listar tudo sem filtro e restringir por chamada.
- `on_tool_call(call)`/`on_tool_result(call, result)` (sync ou async) correm a
  cada tool call; `on_tool_call` devolvendo `False` **veta** a chamada (o
  modelo recebe um `tool_result` de erro, sem a tool executar).
- O `Completion` devolvido ganha atributos extras — compatível com sempre,
  `usage`/`cost` continuam sendo só os da ÚLTIMA chamada ao LLM:

| Atributo extra | O quê |
|---|---|
| `tool_trace` | lista de `{"call", "result", "is_error"}` de TODAS as tool calls do loop |
| `iterations` | quantas rodadas o loop deu |
| `stopped_by_limit` | `True` se parou por bater em `max_iterations` (mesmo caso do `UserWarning`) |
| `usage_total`/`cost_total`/`cost_complete` | agregados de TODAS as chamadas ao LLM no loop |

Em `Agent`/`Squad` (ver [Agentes e times](agents.md)), o mesmo aparece como
`mcp_allowed_tools=`, `on_tool_call=`/`on_tool_result=` no construtor, e
`AgentResult.tool_trace`.

## Ser um servidor MCP (expor suas tools/Agent)

Além de **consumir** MCP (acima), o jangada pode **ser** um servidor MCP — expor
suas funções/`Agent` para clientes (Claude Desktop, Cursor, outro agente)
chamarem. É construído sobre o `Server` **low-level** do SDK (o protocolo), não
sobre o FastMCP — e reaproveita o `tools.py` (o schema sai da assinatura da função).

```python
from jangada_ai import serve_mcp

def somar(a: float, b: float) -> float:
    "Soma dois números."
    return a + b

serve_mcp("minha-calc", tools=[somar])          # transporte stdio (Claude Desktop/Cursor)
```

Expor um **Agent** inteiro (vira a tool `ask`):

```python
from jangada_ai import Agent, LLM, serve_mcp
agente = Agent(LLM("openai", "gpt-4o-mini"), role="suporte", tools=[somar])
serve_mcp("suporte", agent=agente)
```

### Por HTTP (streamable-http)

```python
serve_mcp("minha-calc", tools=[somar], transport="streamable-http", port=8000)
```

Ou monte o app ASGI no seu servidor (FastAPI/Starlette):

```python
from jangada_ai import build_mcp_app
app = build_mcp_app("minha-calc", tools=[somar], path="/mcp")   # Starlette ASGI
```

Requer o extra `jangada-ai[mcp]`; para HTTP, também `starlette` + um servidor ASGI
(`uvicorn`). O **stdio** é o transporte usado por Claude Desktop/Cursor.

> `serve_mcp`/`build_mcp_app`/`build_mcp_server` = **lado servidor**. O `MCPClient`
> (acima) é o **lado cliente**. São complementares.

**Exemplo pronto:** [`jangada-docs-mcp`](https://github.com/nerigleston/jangada-docs-mcp)
é um servidor MCP (feito com `serve_mcp`) que expõe toda a documentação do jangada
ao seu editor — rode sem clonar: `uvx --from git+https://github.com/nerigleston/jangada-docs-mcp jangada-docs-mcp`.

## O que mudou na 1.9.0

- **Compatível com `mcp` 1.x e 2.x.** O extra `[mcp]` agora aceita `mcp>=1.0,<3`;
  cliente, servidor, SSE, streamable-HTTP e stdio foram validados de ponta a ponta
  nas duas versões.
- **Retry só no que é seguro repetir.** Com `keep_alive=True`, a reconexão
  automática repete sozinha apenas operações sem efeito colateral (`list_*`,
  `read_resource`, `get_prompt`, `ping`). `call_tool` só é repetido quando a
  requisição comprovadamente **não saiu** (falha de conexão); em timeout de
  leitura ele reconecta para as próximas chamadas mas **não** repete a atual (evita
  criar um pedido em dobro). Para repetir mesmo assim: `MCPClient(..., retry_tools=True)`.
  Erros 401/403 (e demais 4xx, exceto 408/429) não disparam reconexão.
- **`timeout=` vale para a sessão inteira**, inclusive no stdio — uma tool travada
  no servidor não pendura mais o cliente para sempre.
- **Allowlist aplicada na execução.** `run_agent(..., allowed_tools=[...])` (e o
  `Agent` com `mcp_allowed_tools`) só executa tools que foram de fato oferecidas ao
  modelo; qualquer outro nome volta como `tool_result` de erro, sem executar.
- **SSE detectado pelo caminho da URL** (`/sse?token=...` funciona).
- **Sampling com aprovação.** `MCPClient(..., sampling_llm=llm, on_sampling_request=fn)`:
  `fn(request)` (sync ou async) devolvendo `False` recusa o pedido do servidor. O
  `stopReason` reflete o motivo real (`maxTokens`, `endTurn`, `toolUse`) e
  `prompt_messages` preserva imagens.
- **Segredos fora do `repr`.** Token e headers não aparecem mais em `repr(MCPClient)`
  nem em `repr(MCPServer)`.

### Servidor: autenticação e proteção contra DNS rebinding

```python
from jangada_ai import serve_mcp

serve_mcp(
    "minhas-tools", tools=[consultar_pedido],
    transport="streamable-http", host="0.0.0.0", port=8000,
    auth_token="segredo",                      # exige Authorization: Bearer segredo (401 sem ele)
    allowed_hosts=["mcp.minhaempresa.com"],     # proteção contra DNS rebinding
    allowed_origins=["https://app.minhaempresa.com"],
)
```

`build_mcp_app(...)` aceita os mesmos `auth_token`/`allowed_hosts`/
`allowed_origins`/`security_settings`. Tools **síncronas** (e agente síncrono) rodam
numa thread, sem bloquear as outras sessões. Se o `agent=` exposto tiver `memory`,
a lib avisa: a memória é compartilhada entre todos os clientes.

## Exemplo

- [`examples/mcp_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/mcp_example.py) — cliente MCP.
- [`examples/mcp_server_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/mcp_server_example.py) — **servidor** MCP (stdio).

---

# Vision (imagens)

Imagens entram como `ImagePart` (bytes + mime) e são traduzidas para o formato
nativo de cada SDK. Use sempre um modelo com visão.

```python
from jangada_ai import LLM, Image

llm = LLM("openai", "gpt-4o-mini")

# por caminho
llm.complete("O que aparece aqui?", images=["foto.jpg"])

# por bytes ou base64
img = Image.from_bytes(upload_bytes, "image/png")   # ou Image.from_base64
recibo = llm.parse("Extraia o total.", Recibo, images=[img]).parsed

# rotular cada imagem: tuplas (rótulo, imagem) -> TextPart(rótulo) antes de cada uma
llm.parse("Compare.", Comparacao, images=[("frente", img), ("verso", img2)])
```

Para controle total da ordem (vários blocos texto/imagem, multi-turno), monte
`history=[Message("user", [TextPart(...), ImagePart(...), ...])]` com `prompt=None`.

## Tradução por provider

| Provider     | Formato nativo                          |
|--------------|-----------------------------------------|
| OpenAI/Groq  | `image_url` com data URI                |
| Anthropic    | bloco `image` / `source` base64         |
| Gemini       | `types.Part.from_bytes`                 |

Apenas bytes circulam (use `Image.from_path/bytes/base64`). Combine com
[Structured output](structured-output.md) passando `images=` no `parse()`.

## Vision x Documentos

Para **docx/pdf/csv/xlsx**, prefira [Documentos](documents.md): por padrão a
jangada extrai o texto localmente (mais barato, funciona em modelo sem visão) e
só usa vision quando você força `mode="vision"` ou quando o PDF é escaneado.

## Exemplo

[`examples/vision_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/vision_example.py) — script executável.

---

# Detecção de objetos

`detect_objects()` detecta objetos em uma imagem e devolve as **caixas
delimitadoras em pixels absolutos**. É vision + structured output, então
**funciona em qualquer provider com visão** — não é exclusivo do Gemini.

```python
from jangada_ai import LLM, detect_objects

llm = LLM("gemini", "gemini-2.5-flash")
dets = detect_objects(llm, "foto.png")

for d in dets:
    print(d.label, d.box)   # box = [x1, y1, x2, y2] em pixels
```

Cada `Detection` tem:

- `label` — nome do objeto.
- `box` — caixa em **pixels absolutos**, `[x1, y1, x2, y2]` (canto superior
  esquerdo e inferior direito), já convertida ao tamanho real da imagem.
- `box_2d` — caixa crua do modelo, `[ymin, xmin, ymax, xmax]` normalizada 0–1000.

## Parâmetros

```python
detect_objects(
    llm,
    image,                      # caminho, ImagePart ou bytes via Image.from_bytes
    target="todos os gatos",   # restringe o que procurar (opcional)
    max_objects=10,             # limita a quantidade (opcional)
    instructions="Ignore objetos desfocados; rotule em inglês.",  # ACRESCENTA ao prompt padrão
    prompt=None,                # sobrescreve a instrução inteira (opcional)
    image_size=(800, 600),      # informe se o formato não for detectável
)
```

- `instructions` **soma** ao prompt padrão — útil para dar contexto da cena,
  regras de rotulagem ou o que ignorar, sem perder o formato garantido.
- `prompt` **substitui** a instrução inteira (o schema ainda garante a saída
  JSON). Pode combinar os dois: `prompt=` define a base e `instructions=` agrega.

Versão async: `await adetect_objects(llm, image, ...)`.

## Funciona em todos os providers?

**Sim, mecanicamente.** A convenção `box_2d [ymin,xmin,ymax,xmax]` em escala
0–1000 é a do **Gemini** (instruída via prompt e validada por schema Pydantic),
então:

- **Gemini** — mais preciso (formato nativo de treino).
- **OpenAI (gpt-4o, ...)** — funciona bem.
- **Anthropic (Claude vision)** — detecta, mas a precisão das coordenadas varia.

Use sempre um modelo com visão. Veja também [Vision](vision.md) e
[Structured output](structured-output.md).

## Robustez

A leitura é **tolerante**: se o modelo localizar a chave (ex.: devolver `objetos`
em vez de `objects`) ou cortar um `box_2d` (≠ 4 números), o `detect_objects`
ainda extrai o que dá e **descarta as caixas inválidas** em vez de retornar vazio.

## Dimensões da imagem

As dimensões são lidas direto dos bytes (PNG, JPEG, GIF, BMP, WEBP) sem
dependência externa. Para outros formatos, passe `image_size=(largura, altura)`.

## Exemplo

[`examples/detect_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/detect_example.py) — script executável.

---

# Step-back prompting

`step_back()` gera, a partir de uma pergunta específica, **uma pergunta
conceitualmente mais ampla**. É uma técnica de transformação de query para RAG:
a pergunta mais genérica recupera documentos de **contexto amplo** (princípios,
categorias, fundamentos) que a busca pela pergunta original sozinha costuma
perder. O padrão é buscar com **as duas** e juntar os trechos.

```python
from jangada_ai import LLM, step_back

llm = LLM("openai", "gpt-4o-mini")
ampla = step_back(llm, "Quais são as opções de tratamento para catarata?")
print(ampla)
# "Quais são as abordagens cirúrgicas e farmacológicas para o manejo da
#  opacidade do cristalino?"
```

É só LLM + structured output, então **funciona em qualquer provider** — não
depende de visão nem do extra `[rag]`.

## Uso típico com RAG

Busque o contexto com a pergunta original **e** com a step-back, e una os
resultados antes de responder:

```python
from jangada_ai import LLM, step_back
from jangada_ai.rag import RAG, vector_store

llm = LLM("openai", "gpt-4o-mini")
emb = LLM("openai", "text-embedding-3-small")
rag = RAG(emb, vector_store("postgresql://..."), chat=llm)

pergunta = "Quais são as opções de tratamento para catarata?"
ampla = step_back(llm, pergunta)

especificos = rag.search(pergunta)
gerais = rag.search(ampla)
# combine `especificos` + `gerais` (deduplique) e responda com o contexto unido.
```

## Parâmetros

```python
step_back(
    llm,
    query,                          # a pergunta específica
    instructions="Foco em medicina; responda em pt-BR.",  # ACRESCENTA ao prompt padrão
    prompt=None,                    # sobrescreve a instrução inteira (opcional)
    params={"temperature": 0.2},   # params de geração (opcional)
)
```

- `instructions` **soma** ao prompt padrão — útil para fixar domínio, idioma ou
  o que enfatizar, sem perder o formato garantido pelo schema.
- `prompt` **substitui** a instrução inteira (o schema ainda garante a saída).
  Ao sobrescrever, inclua a pergunta no seu texto — o `{query}` só é interpolado
  no prompt padrão.

Versão async: `await astep_back(llm, query, ...)`.

## Robustez

A leitura é **tolerante**: usa o structured output quando vem válido, cai no
texto cru se o modelo ignorar o schema e, em último caso, devolve a própria
`query` — **nunca** retorna vazio.

## Exemplo

[`examples/stepback_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/stepback_example.py) — script executável.

Veja também [RAG](rag.md) e [Structured output](structured-output.md).

---

# Transcrição de áudio (speech-to-text)

`LLM.transcribe()` converte áudio em texto. **Não é suportado por todos os
providers** — depende de a API do provider aceitar áudio:

| Provider  | Suporta? | Como         | Modelos típicos                                  |
|-----------|----------|--------------|--------------------------------------------------|
| OpenAI    | ✅       | endpoint dedicado | `gpt-4o-transcribe`, `gpt-4o-mini-transcribe`, `whisper-1` |
| Groq      | ✅       | endpoint dedicado | `whisper-large-v3`, `whisper-large-v3-turbo`     |
| Gemini    | ✅       | multimodal (`generateContent`) | `gemini-2.5-flash`, `gemini-2.5-pro` |
| Anthropic | ❌       | —            | a API do Claude não aceita áudio                 |

> Tentar transcrever no Anthropic levanta `UnsupportedError`. O "voice" do
> Claude é recurso de produto (app/Claude Code), não da API do modelo.

## Uso

```python
from jangada_ai import LLM, Audio

# OpenAI
llm = LLM("openai", "gpt-4o-transcribe")
print(llm.transcribe("entrevista.mp3").text)

# Groq (mais rápido/barato)
llm = LLM("groq", "whisper-large-v3-turbo")
print(llm.transcribe("entrevista.mp3").text)

# Gemini (multimodal)
llm = LLM("gemini", "gemini-2.5-flash")
print(llm.transcribe("entrevista.mp3").text)
```

Entradas aceitas: caminho, `AudioPart` ou bytes via `Audio.from_bytes`:

```python
audio = Audio.from_bytes(blob, "audio/wav", name="fala.wav")
llm.transcribe(audio)
```

Async: `await llm.atranscribe(audio)`.

## Opções por provider

Os kwargs extras vão direto ao provider quando ele os aceita:

```python
# OpenAI/Groq aceitam language, prompt, response_format, temperature, ...
llm.transcribe("audio.mp3", language="pt", response_format="text")

# Gemini aceita prompt= como instrução (ex.: incluir timestamps)
llm.transcribe("audio.mp3", prompt="Transcreva com marcações de tempo.")
```

## Fallback entre providers de áudio

Como qualquer chamada da jangada, `transcribe()` honra retry e fallback:

```python
from jangada_ai import LLM

primario = LLM("groq", "whisper-large-v3-turbo")
reserva  = LLM("openai", "gpt-4o-transcribe")
stt = primario.with_fallback(reserva)

stt.transcribe("audio.mp3")   # tenta Groq; se falhar (5xx/timeout), vai pro OpenAI
```

> `UnsupportedError` **não** entra no failover padrão — é erro de configuração
> (provider sem áudio), não falha transitória. Veja [Erros](errors.md) e
> [Retry e fallback](retry-fallback.md).

## Formatos e limites

- OpenAI/Groq: flac, mp3, mp4, mpeg, mpga, m4a, ogg, wav, webm — até ~25 MB.
- Gemini: áudio inline até ~20 MB no total do request (use a Files API do SDK
  para arquivos maiores).

## Exemplo

[`examples/transcribe_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/transcribe_example.py) — script executável.

---

# Documentos (docx, pdf, csv, xlsx)

Anexe arquivos a qualquer chamada com `files=`. Por padrão a jangada **extrai o
texto** do arquivo localmente em vez de usar vision — é mais barato e funciona
em qualquer modelo, inclusive os sem visão.

```bash
pip install "jangada-ai[files]"   # pypdf, python-docx, openpyxl
```

```python
from jangada_ai import LLM, Document

llm = LLM("openai", "gpt-4o-mini")

# caminhos: tipo detectado pela extensão
llm.complete("Resuma:", files=["relatorio.pdf", "contrato.docx"])

# xlsx: TODAS as abas entram, cada uma rotulada (## Aba: ...)
llm.complete("Maior total?", files=[Document("vendas.xlsx", max_rows=200)])

# bytes em memória (upload/fila) — informe o nome para detectar o tipo
llm.parse("Há duplicadas?", Relatorio, files=[Document(blob, name="x.csv")])

# forçar vision (PDF escaneado / quando o layout importa)
llm.complete("Transcreva:", files=[Document("scan.pdf", mode="vision")])
```

## Regra de `mode`

| `mode`     | Comportamento                                                     |
|------------|------------------------------------------------------------------|
| `"auto"`   | (padrão) extrai texto de csv/xlsx/docx/pdf-com-texto; imagem → vision |
| `"text"`   | força extração de texto (erro se o formato não tiver texto)      |
| `"vision"` | força o caminho de imagem                                        |

## Por que não usar vision para tudo

- **Mais barato**: texto custa muito menos que tokens de imagem.
- **Funciona em qualquer modelo**, inclusive sem visão.
- **Preserva tabelas** como markdown.

Um PDF **sem** camada de texto (escaneado) levanta `DocumentError` sugerindo
`mode="vision"` — nunca devolve um bloco vazio em silêncio.

## Detalhes

- `files=` existe em `complete/parse/stream` (sync e async) e convive com `images=`.
- A conversão acontece na fronteira do client (`files.py` → `to_part()`): cada
  arquivo vira `TextPart` ou `ImagePart`, então os adapters nunca veem formato
  de documento.
- Formatos: `.csv`, `.tsv`, `.xlsx`, `.xlsm`, `.docx`, `.pdf`, além de texto
  puro (`.txt`, `.md`, `.json`, ...).

Relacionado: [Vision](vision.md), [Structured output](structured-output.md).

## O que mudou na 1.9.0

- **PDF escaneado com `mode="vision"` funciona de verdade**: cada página é
  renderizada em PNG (até `Document(..., max_pages=20)`) e vai como imagem. Usa o
  `pypdfium2` (agora no extra `[files]`) ou o `pymupdf`, se instalado; sem nenhum
  dos dois, o erro diz o que instalar. A lib nunca manda um PDF como se fosse
  imagem (isso dava 400 no provider). Para montar as partes você mesmo:
  `jangada_ai.files.to_parts(doc)` devolve uma parte por página.
- **Encoding de CSV**: tenta UTF-8 (com e sem BOM), depois cp1252 e latin-1 — CSV
  exportado do Excel brasileiro não vira mais `�`.
- **CSV/TSV grandes** são lidos em streaming até `max_rows`; `.tsv` usa o extrator
  próprio.
- **PDF protegido**: tenta abrir com senha vazia; se não der, levanta
  `DocumentError` claro (PDF corrompido também).
- **xlsx**: célula de fórmula sem valor calculado aparece como
  `[fórmula sem valor calculado: =...]` em vez de vazia; `|` e quebras de linha nas
  células são escapados na tabela markdown.
- **Bytes sem nome**: o formato é detectado pelo conteúdo (PDF, PNG, JPEG, GIF,
  WEBP, BMP, docx, xlsx).

```python
from jangada_ai import LLM, Document

llm = LLM("gemini", "gemini-3.5-flash")
comp = llm.complete("Transcreva a nota fiscal.",
                    files=[Document("nota_escaneada.pdf", mode="vision", max_pages=3)])
```

## Exemplo

[`examples/files_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/files_example.py) — script executável.

---

# RAG (embeddings + busca vetorial/híbrida)

A jangada cobre as partes "de LLM" do RAG (embeddings + montar o contexto) e traz
um módulo `jangada_ai.rag` opcional com chunking, vector store (pgvector/Mongo)
e busca híbrida.

```bash
pip install "jangada-ai[rag]"   # psycopg (pgvector) + pymongo (Mongo)
```

## Embeddings (`embed`)

Capacidade opcional, igual ao áudio: **OpenAI, Gemini e Mistral** suportam (além dos
compatíveis Azure/OpenRouter); **Anthropic e Groq** levantam `UnsupportedError`.

```python
from jangada_ai import LLM

emb = LLM("openai", "text-embedding-3-small")   # ou ("gemini", "gemini-embedding-001")
emb.embed("uma frase")            # -> vetor (list[float])
emb.embed(["a", "b"])             # -> lista de vetores
emb.embed(textos, task="document")  # task: "document" ao indexar, "query" ao buscar
emb.embed(textos, batch_size=50)    # Gemini: textos por requisição (1..100, padrão 100)
```

`task` vira `task_type` no Gemini (`RETRIEVAL_DOCUMENT`/`RETRIEVAL_QUERY`); a
OpenAI ignora. `dimensions` (OpenAI) / `output_dimensionality` (Gemini) vão via `**opts`.

`batch_size` (1..100, padrão 100) controla quantos textos vão por requisição: a
API do Gemini aceita no máximo 100 por `BatchEmbedContents`, então listas maiores
são fatiadas nesse tamanho. Só o Gemini usa — OpenAI/Azure/OpenRouter não têm esse
limite e ignoram o parâmetro. Valores fora de 1..100 levantam `ValueError`.

| Provider | Embeddings? | Modelo típico |
|----------|:-----------:|---------------|
| OpenAI   | ✅ | `text-embedding-3-small` / `-large` |
| Gemini   | ✅ | `gemini-embedding-001` / `gemini-embedding-2` |
| Anthropic| ❌ | — (use Voyage/Cohere por fora) |
| Groq     | ❌ | — |

Com a observabilidade automática ativada, cada `embed()`/`aembed()` registra
latência, tokens, custo estimado, capability `embeddings` e o array completo dos
vetores em `output`. Embeddings executados dentro de
`observability_session(name="rag.documents.ingest")` entram no mesmo trace da
ingestão.

Se o Gemini não devolver tokens na resposta de embedding, a Jangada usa
`count_tokens()` com o mesmo lote. O custo combina essa contagem com o preço do
catálogo `jangada.dev.br/prices.json`; uma aproximação local só é usada se a
contagem do provider também falhar.

## Pipeline completo (`RAG`)

```python
from jangada_ai import LLM
from jangada_ai.rag import RAG, vector_store

emb  = LLM("openai", "text-embedding-3-small")
chat = LLM("openai", "gpt-4o-mini")

# o store é escolhido pela STRING DE CONEXÃO (só passe a sua DATABASE_URL_VECTOR)
store = vector_store("postgresql://user:senha@host:5432/db")   # ou "mongodb+srv://..."
rag = RAG(emb, store, chat=chat, k=5)   # k = nº de trechos no contexto (ajustável)

rag.add_document("manual.pdf", metadata={"fonte": "manual"})   # extrai -> chunk -> embed -> grava
resposta = rag.ask("Como faço backup?", mode="hybrid")          # usa o k do RAG
mais = rag.ask("Como faço backup?", k=10, mode="hybrid")        # override por chamada
print(resposta.text)
for s in resposta.sources:
    print(s.score, s.chunk.content[:80])
```

## Vector store por string de conexão

`vector_store(url)` detecta o adapter pelo esquema:

| URL | Adapter | Busca |
|-----|---------|-------|
| `postgresql://` / `postgres://` | pgvector (Postgres) | cosseno (`<=>`) + full-text (`tsvector`) |
| `mongodb://` / `mongodb+srv://` | MongoDB | Atlas `$vectorSearch` + `$text` (fallback cosseno client-side) |
| `memory` / `None` | em memória | cosseno + keyword (sem deps) |

As tabelas/coleções e índices são criados sozinhos no primeiro uso (`setup`).

### Busca textual em português (pgvector)

O `PgVectorStore` usa a configuração `'simple'` do Postgres por padrão (sem
stemming nem stopwords). Para conteúdo em **português**, passe
`text_config="portuguese"` — melhora a parte lexical da busca híbrida (stemming PT
+ stopwords):

```python
from jangada_ai.rag import vector_store

store = vector_store("postgresql://...", text_config="portuguese")
```

A config é fixada na **criação** da tabela (a coluna `tsv` é GENERATED), então
defina-a antes do primeiro `setup`. Vale qualquer `regconfig` do Postgres
(`english`, `spanish`, ...).

## Modos de busca

`mode="vector" | "text" | "hybrid"`:

- **vector** — só similaridade do embedding.
- **text** — lexical: **BM25** no store em memória (com `rank_bm25`; cai para
  contagem de termos sem ele) e full-text nativo no pgvector (`tsvector`) / Mongo (`$text`).
- **hybrid** — combina os dois por **Reciprocal Rank Fusion (RRF)**.

O balanço vetorial × lexical sai de `weights=(vetorial, texto)` ou do atalho
**`alpha`** (0 = só BM25/texto, 1 = só vetorial; `alpha` vira `weights=(alpha, 1-alpha)`):

```python
RAG(emb, store, chat=chat, alpha=0.5)   # equilíbrio; 0.0 = só BM25, 1.0 = só vetorial
```

```python
rag.search("backup incremental", k=5, mode="vector")   # só vetorial
rag.search("backup incremental", k=5, mode="hybrid")   # vetorial + texto (RRF)
```

## Reranking (maior salto de qualidade)

O retriever traz candidatos (bom *recall*), mas a ordem nem sempre é a melhor.
Um **reranker** — modelo treinado em "este trecho responde a esta pergunta?" —
reordena os candidatos e fica com os melhores. Em RAG é o maior ganho de
qualidade por esforço.

Com `reranker=`, o `RAG` busca **mais** candidatos (`fetch_k`, padrão `k*4`) e
reordena, devolvendo os `k` melhores:

```python
from jangada_ai.rag import RAG, Reranker, vector_store

rag = RAG(emb, vector_store("memory"), chat=chat, reranker=Reranker.cohere())
rag.ask("Como faço backup incremental?")   # busca k*4 e reordena para os k melhores
```

Construtores do `Reranker`:

- `Reranker.cohere(model="rerank-v3.5")` — Cohere Rerank (precisa de `cohere` e
  `COHERE_API_KEY`).
- `Reranker.voyage(model="rerank-2.5")` — Voyage Rerank (precisa de `voyageai` e
  `VOYAGE_API_KEY`).
- `Reranker.fn(lambda query, docs: [scores])` — qualquer scorer (ex.: um
  cross-encoder local ou um LLM seu).

Extra opcional: `pip install "jangada-ai[rerank]"`. Controle fino:
`RAG(..., reranker=..., fetch_k=40)`; por chamada, `rag.search(q, rerank=False)`
desliga e `rerank=OutroReranker` sobrescreve.

## Parâmetros ajustáveis

Definidos no `RAG(...)` (padrão) e/ou por chamada:

```python
rag = RAG(
    emb, store, chat=chat,
    k=5,                      # nº de trechos no contexto
    min_score=0.25,           # descarta trechos abaixo dessa similaridade
    max_context_chars=6000,   # orçamento do contexto (trunca o excedente)
    chunker=meu_chunker,      # função(text)->list[str] (troca o chunking padrão)
    rrf_k=60,                 # constante do RRF (híbrido)
    weights=(1.0, 0.5),       # pesos (vetorial, texto) no híbrido
    chunk_size=1000, overlap=200,
)

# filtro por metadata (escopar por documento/fonte/tenant) + override por chamada
rag.ask("backup?", k=8, filter={"fonte": "manual"}, min_score=0.3, mode="hybrid")
rag.search("backup?", filter={"tenant": "acme"}, mode="vector")
```

- **`filter`** vira `metadata @> ...` no pgvector e `$match` em `metadata.<chave>` no Mongo.
- **`min_score`** usa a similaridade real (cosseno no vetorial, rank no texto).
- **`max_context_chars`** corta os trechos que não couberem (mantém ao menos um).
- **`weights=(v, t)`** pondera os rankings vetorial e de texto na fusão RRF.

## Estratégias avançadas de retrieval (opt-in)

Passe `strategy=` no `search`/`ask`. Padrão: busca simples (continua igual).

- **multi-query** — o LLM gera variações da pergunta, busca todas e funde por RRF
  (pega sinônimos/ângulos). `rag.ask(q, strategy="multi_query")`.
- **parent-document** — indexe filhos pequenos (precisos) guardando o trecho-pai
  grande; a busca devolve o pai (melhor contexto):
  ```python
  rag.add_document("manual.pdf", parent_chunk_size=4000)   # filhos + pai
  rag.ask("Como faço X?", strategy="parent_document")
  ```
- **contextual compression** — após buscar, o LLM extrai de cada trecho só o
  relevante (encolhe o contexto/custo): `rag.ask(q, compress=True)`.

Combinam entre si e com o `reranker=`.

## Indexação incremental

`sync_document`/`sync_texts` reindexam **só o que mudou** (dedup por hash do
conteúdo): embeda os chunks novos e remove os que sumiram. Requer `document_id`
e um store com suporte: memória ou PostgreSQL/pgvector. MongoDB ainda não oferece
as primitivas incrementais.

```python
rag.sync_document("manual.pdf", name="manual.pdf", document_id="manual")
# {'added': 3, 'removed': 1, 'unchanged': 42}  -> só os 3 novos foram embedados
```

No pgvector, a primeira sincronização migra a tabela de forma idempotente:
adiciona/backfill a coluna `hash`, remove duplicatas exatas por
`(document_id, hash)` e cria um índice único parcial. Linhas antigas sem
`document_id` são preservadas. Cada atualização roda numa transação protegida por
advisory lock do `document_id`, portanto sincronizações concorrentes não misturam
versões. Mudanças apenas de metadata/posição são persistidas sem reembedding; se
extração, chunking, embedding ou gravação falhar, a versão anterior continua
disponível.

## Qual recurso usar? (resumo)

Todos são **opt-in** — a lib funciona sem nenhum. Some-os conforme a necessidade:

| Quero… | Use | Custo extra |
|--------|-----|-------------|
| Melhorar muito a ordem dos trechos | `reranker=Reranker.cohere()` | 1 chamada de rerank |
| Pegar sinônimos/perguntas vagas | `strategy="multi_query"` | N buscas + 1 LLM |
| Contexto melhor sem perder precisão | `parent_chunk_size=` + `strategy="parent_document"` | — |
| Reduzir tokens de contexto | `compress=True` | 1 LLM por trecho |
| Chunks que não cortam ideias | `chunker=semantic_chunker(emb)` | embeddings no índice |
| Reindexar barato (só o que mudou) | `sync_document(...)` | — |

Receita recomendada para produção: **semantic chunking** (índice) +
**reranker** (busca). Acrescente **multi-query** se as perguntas forem vagas.

Exemplo combinando tudo:

```python
from jangada_ai import LLM
from jangada_ai.rag import RAG, Reranker, semantic_chunker, vector_store

emb = LLM("openai", "text-embedding-3-small")
rag = RAG(
    emb, vector_store("postgresql://..."), chat=LLM("openai", "gpt-4o-mini"),
    chunker=semantic_chunker(emb),     # índice por assunto
    reranker=Reranker.cohere(),        # ordem por relevância
)
rag.sync_document("manual.pdf", name="manual")          # incremental
ans = rag.ask("Como faço backup?", strategy="multi_query", compress=True)
```

POC executável com todos os recursos lado a lado:
[`pocs/rag-advanced-poc`](https://github.com/nerigleston/jangada).

## Chunking

```python
from jangada_ai.rag import chunk_text, semantic_chunker
chunk_text(texto, size=1000, overlap=200)        # padrão: tamanho fixo, sem cortar palavras

# semantic chunking (opt-in): quebra por similaridade, não por tamanho
RAG(emb, store, chunker=semantic_chunker(emb))   # frases do mesmo assunto ficam juntas
```

Relacionado: [Documentos](documents.md) (extração de texto reaproveitada no RAG)
e [Observabilidade](observability.md).

## O que mudou na 1.9.0

- **API async completa**: `aadd_texts`, `aadd_document`, `async_texts`,
  `async_document`, `asearch` e `aask` (usam `aembed`/`acomplete`; o store roda em
  thread). O `Reranker` ganhou `arerank`, e há `aexpand_queries`/`acompress_results`.
- **Busca híbrida corrigida no pgvector/Mongo.** A fusão (RRF) usava a identidade
  do objeto e, nesses bancos, nunca juntava o mesmo trecho vindo das duas buscas —
  devolvia duplicados. Agora usa uma chave estável (id → documento+hash → conteúdo).
- **`min_score` no modo híbrido** filtra pela **similaridade vetorial** (antes da
  fusão), não pelo score do RRF (que fica perto de 0,03 e zerava os resultados).
- **O índice em memória não é mais alterado pela busca**: `parent_document` e
  `compress=True` trabalham em cópias.
- **Prompt padrão** delimita o contexto recuperado entre as tags `<contexto>` e
  instrui o modelo a tratá-lo como dado, não como instrução (mitiga prompt
  injection vindo de documentos). Prompts customizados com chaves literais (ex.:
  um exemplo de JSON) não quebram mais.
- **Embeddings em lotes** de 128 textos (os adapters OpenAI/Azure/OpenRouter e
  Mistral também fatiam), então PDFs grandes não estouram o limite do provider.
- **pgvector**:
  - modelos com mais de 2000 dimensões (ex.: `gemini-embedding-001`,
    `text-embedding-3-large`, 3072) são indexados via `halfvec` (exige pgvector ≥
    0.7), até 4000; acima disso a tabela fica sem índice HNSW, com aviso;
  - `add()`/`add_texts` devolvem quantos trechos foram **realmente** inseridos
    (duplicatas por documento+hash são descartadas);
  - o embedding é calculado fora da transação e do advisory lock, a conexão é
    protegida por lock entre threads, e há `close()` + `with PgVectorStore(...)`;
  - o nome da tabela é validado (aceita `schema.tabela`).
- **Mongo Atlas**: grava o `hash` dos trechos, aceita `filter_fields=[...]` (campos
  de metadata declarados como `filter` no índice — necessário para `filter=` na
  busca), cai para a busca no cliente só em erro do Atlas (com aviso) e tem
  `close()` + context manager.

```python
store = vector_store("postgresql://...", table="docs", text_config="portuguese")
with store:
    rag = RAG(chat=LLM("openai", "gpt-5-mini"), embedder=LLM("openai", "text-embedding-3-small"), store=store)
    await rag.aadd_document("manual.pdf")
    resp = await rag.aask("Qual o prazo de garantia?", min_score=0.3)  # min_score = cosseno
```

## Exemplo

[`examples/rag_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/rag_example.py) — script executável.

---

# Streaming

Receba tokens incrementais com `stream()` (sync) ou `astream()` (async).

```python
for token in llm.stream("Conte sobre {{x}}", x="João Pessoa"):
    print(token, end="")
```

```python
async for token in llm.astream("..."):   # ex.: FastAPI StreamingResponse
    ...
```

## Retry e fallback no streaming

O retry e o fallback acontecem **antes do primeiro token**: se a abertura do
stream falhar com erro transitório, a jangada tenta de novo (backoff) e, se
preciso, cai para o próximo candidato — tudo antes de você receber qualquer
conteúdo. Depois que o primeiro token sai, o stream segue até o fim.

Veja [Retry e fallback](retry-fallback.md) para a política completa.

## Observações

- `stream()`/`astream()` aceitam os mesmos `system=`, `history=`, `images=`,
  `files=` e `params=` das outras chamadas.
- Para custo e tokens use as chamadas não-stream (`complete`/`parse`), que
  retornam `usage`/`cost` na resposta — veja [Custo e tokens](cost.md).

## O que mudou na 1.9.0

- Chunks sem conteúdo (ex.: o primeiro chunk da Azure com o filtro de conteúdo)
  não quebram mais o stream, e a conexão é fechada se você parar de consumir o
  iterador no meio.
- Depois do stream, o provider guarda `usage` e `finish_reason` em
  `llm.provider.last_stream` (OpenAI, Azure, DeepSeek, Mistral, Ollama). É por
  instância: com várias threads usando o mesmo `LLM`, leia logo após o stream.
- O stream é reportado à [observabilidade](observability.md) ao final.
- Com tools nativas, o stream emite só o texto (Anthropic/Gemini/OpenAI); em
  Mistral e Ollama, stream + tools nativas levanta `UnsupportedError`.

## Exemplo

[`examples/async_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/async_example.py) — script executável.

---

# Retry e fallback

A jangada combina duas defesas contra falhas de API: **retry com backoff** no
mesmo candidato e **fallback** para outro modelo/provider.

```python
from jangada_ai import LLM

primario = LLM("openai", "gpt-4o-mini")
reserva  = LLM("anthropic", "claude-haiku-4-5-20251001")

llm = primario.with_fallback(reserva)
llm.complete("...")   # tenta o primário (com retries); se falhar, vai pro reserva
```

## Como a ordem funciona

Por candidato, o cliente tenta `max_retries + 1` vezes com backoff exponencial
(com jitter) antes de cair para o próximo candidato:

```
[primário] tenta → retry → retry → falhou ─▶ [reserva] tenta → retry → ...
```

- **Retry** acontece em erros transitórios (`backoff_on`, padrão
  `errors.TRANSIENT`: rate limit, timeout, conexão, 5xx).
- **Fallback** acontece nos erros de `retry_on` (padrão `DEFAULT_FAILOVER`:
  rate limit, timeout, conexão, 5xx, **404**).
- `NotFoundError` (404) **não** repete no mesmo candidato, mas **faz** fallback.
- `auth` e `bad_request` **não** entram no failover padrão — falham de imediato.

## Parâmetros

```python
LLM(
    "openai", "gpt-4o-mini",
    max_retries=2,          # tentativas extras por candidato
    backoff_base=0.5,       # segundos
    backoff_max=8.0,
    jitter=True,
    retry_on=None,          # default: errors.DEFAULT_FAILOVER
    backoff_on=None,        # default: errors.TRANSIENT
    fallbacks=[reserva],
)
```

Os erros são normalizados (com `status_code`) — veja [Erros](errors.md). Para o
custo agregado entre candidatos, veja [Custo e tokens](cost.md).

## O que mudou na 1.9.0

- Quando todos os candidatos falham, o erro final é enviado à
  [observabilidade](observability.md) como observation `ERROR` (uma vez por chamada,
  não por tentativa).
- `embed`/`aembed` agora têm retry com backoff em erro transitório — mas **sem
  fallback** para outro modelo: vetores de modelos diferentes não são comparáveis
  e corromperiam o índice.
- Recusas e respostas vazias viram erros normalizados que entram no failover (veja
  [Erros](errors.md)); prompt bloqueado por safety no Gemini não é repetido.

## Exemplo

[`examples/fallback_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/fallback_example.py) — script executável.

---

# Custo e tokens

Toda resposta bem-sucedida volta com `usage` (tokens) e `cost` (USD estimado).

```python
comp = llm.complete("...")
print(comp.usage)   # {"input_tokens": ..., "output_tokens": ...}
print(comp.cost)    # ex.: 0.000123  (USD, aproximado)
```

O cliente chama `pricing.compute_cost()` após cada sucesso e seta
`Completion.cost`. `FlowResult` e `GraphResult` **agregam** `usage`/`cost` ao
longo da cadeia.

## Tabela de preços

Os preços vêm de um catálogo JSON (aproximados, por 1M de tokens). Você pode
registrar/ajustar em runtime:

```python
from jangada_ai import register_price, price_for

register_price("meu-modelo", 0.5, 1.5)   # USD por 1M tokens (input, output)
print(price_for("gpt-4o-mini"))
```

> ⚠️ Os valores são **aproximados** e servem para estimativa/observabilidade —
> não trate como fonte de billing.

## Preços sempre atualizados (sem atualizar a lib)

Os preços moram num **catálogo JSON** (não mais hardcoded). A cópia embutida no
pacote (`prices.json`) é só o **fallback offline**; como preços de API mudam toda
hora, a jangada publica o catálogo em `jangada.dev.br/prices.json` e o aplica
**sozinha, sem você escrever nada** — e **sem precisar dar `pip install -U`**.

**Automático (padrão).** Na primeira vez que um custo é calculado (logo abaixo de
cada chamada de provider, em `compute_cost`), a lib dispara **em background** um
refresh do catálogo, cacheado por **1 dia**. Você só usa o LLM normalmente:

```python
llm = jangada_ai.LLM(provider="openai", model="gpt-4o-mini")
comp = llm.complete("...")
print(comp.cost)   # já tende a usar os preços do dia (refresh roda em background)
```

- **Não bloqueia**: a busca roda numa thread daemon; o `import` nunca toca a rede e
  a chamada não trava. As primeiras chamadas usam o embutido até o refresh terminar.
- **No máximo 1x/dia**: cache em `~/.cache/jangada/prices.json` (e 1x por processo).
- **Resiliente**: rede falhou? fica no cache/embutido — nunca levanta.
- **Desligar**: env `JANGADA_NO_PRICE_REFRESH=1`.

**Manual (`refresh_prices`).** Para controle explícito/síncrono — garantir preços
frescos no boot, forçar agora, ou apontar outra URL:

```python
jangada_ai.refresh_prices()                          # síncrono, respeita o cache
jangada_ai.refresh_prices(ttl=3600, force=True)      # revalida/ força agora
jangada_ai.refresh_prices(url="https://meu-espelho/prices.json")
```

Default da URL: `https://jangada.dev.br/prices.json` (override por `url=` ou env
`JANGADA_PRICES_URL`; não precisa de `.env`).

O override manual (`register_price`) tem prioridade sobre tudo — use para fixar um
número exato de um modelo específico.

## Custo multimodal

- **Imagem (vision)**: não há preço separado — os providers já contam os tokens da
  imagem dentro de `input_tokens`. O custo da imagem já sai pela tabela normal.
- **Áudio (transcrição)**: cobrado **por minuto**, não por token. O custo só sai
  quando o `usage` traz a duração (`audio_seconds`): passe
  `response_format="verbose_json"` na transcrição **ou** informe a duração em
  `Audio.from_bytes(dados, mime, duration=...)`. Registre/ajuste com
  `register_audio_price("whisper-1", 0.006)` (USD por minuto).
- **Detecção**: `detect_objects`/`adetect_objects` devolvem só `list[Detection]`
  (sem custo). Para o custo, use `detect_objects_full`/`adetect_objects_full`, que
  devolvem um `DetectionResult` com `.detections` **e** `.completion`/`.cost`/`.usage`.

## Onde isso aparece

- `Completion.cost` / `Completion.usage` em cada chamada.
- Totais agregados em [Fluxos e Graph](flows.md).
- No [Debug passo a passo](debug.md), o custo de cada etapa é exibido no trace.

## O que mudou na 1.9.0

### Contrato de usage

Todos os adapters devolvem `usage` no mesmo formato:

| Chave | Significado |
|-------|-------------|
| `input_tokens` | **total** de input, já incluindo tokens de cache |
| `output_tokens` | total de output (no Gemini inclui os tokens de thinking) |
| `cache_read_tokens` | parte do input lida do cache (opcional) |
| `cache_write_tokens` | parte do input gravada no cache (opcional) |
| `reasoning_tokens` | parte do output gasta raciocinando (só informativa) |
| `server_tool_requests` | chamadas de tools nativas, ex. `{"web_search": 2}` |

`compute_cost` cobra o input não-cacheado a preço cheio, a leitura/escrita de cache
pelo preço de cache do modelo, o output uma única vez (reasoning **não** é cobrado
em dobro) e soma a **taxa por chamada** das tools nativas. Antes, cache e thinking
eram ignorados — Gemini com thinking e Claude com prompt caching saíam bem mais
baratos que a fatura real.

### Preço de cache, faixa acima de 200k e taxas de tools

- Cada regra pode ter `cache_read`/`cache_write` (USD por 1M tokens). Sem eles, a
  lib usa o multiplicador documentado da família (Claude 0,1×/1,25×; Gemini 0,1×;
  gpt-5 0,1×; gpt-4.1/o3/o4-mini 0,25×; gpt-4o/o1/o3-mini 0,5×; desconhecido 1×).
- `above_200k=(in, out)` aplica outro preço quando o prompt passa de 200k tokens
  (ex.: Gemini 2.5 Pro, 3.1 Pro).
- Taxas por chamada de tools nativas (catálogo `tool_fees`): web search da
  Anthropic e da OpenAI US$ 10/1k, Google Search no Gemini 3.x US$ 14/1k queries,
  file search da OpenAI US$ 2,50/1k, conectores da Mistral etc. Entram no
  `Completion.cost`. Cotas gratuitas **não** são descontadas.
- O casamento de modelo ganhou fronteira: `claude-opus-4` volta a custar 15/75
  (não herda o preço do Opus 4.5+), `gpt-5-pro`/`o3-mini` não pegam a regra do
  modelo base, e um modelo sem regra devolve `None` em vez de um preço errado.
- Um hit de cache da própria lib devolve `cost=0.0` e `cached=True`.

```python
from jangada_ai import register_price
from jangada_ai.pricing import register_tool_fee, compute_cost

register_price(r"meu-modelo", 1.0, 4.0, cache_read=0.1, above_200k=(2.0, 8.0))
register_tool_fee("web_search", 12.0, provider="meu-provider")   # USD por 1000 chamadas

compute_cost("claude-sonnet-5", {"input_tokens": 10_000, "cache_read_tokens": 8_000,
                                  "output_tokens": 500,
                                  "server_tool_requests": {"web_search": 1}},
             provider="anthropic")
```

O refresh remoto do catálogo agora só aceita `https`, valida tipos e tamanhos,
rejeita regex perigosa e **substitui** o lote remoto a cada atualização (antes
acumulava).

## Exemplo

[`examples/retry_cost_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/retry_cost_example.py) — script executável.

[`examples/pricing_refresh_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/pricing_refresh_example.py) — preços dinâmicos com `refresh_prices()`.

---

# Cache de respostas

A jangada pode **cachear respostas do LLM** para economizar tokens e latência —
plugado via `LLM(..., cache=...)`. O cliente consulta o cache **antes** de chamar
o provider e o popula **depois** de uma resposta bem-sucedida. Dois modos:
**exato** e **semântico**.

## Cache exato

Acerta só quando a requisição é **idêntica** (mesmo método, escopo
`provider`/`model`/`params` e mensagens). LRU com `max_size` e `ttl` opcionais,
sem custo de embedding.

```python
from jangada_ai import LLM, ExactCache

llm = LLM("openai", "gpt-4o-mini", cache=ExactCache(max_size=512, ttl=3600))
llm.complete("Resuma a teoria da relatividade.")   # chama o provider
llm.complete("Resuma a teoria da relatividade.")   # vem do cache (idêntico)
```

## Cache semântico

Acerta quando a pergunta é **suficientemente parecida** com uma anterior do mesmo
escopo (similaridade de cosseno ≥ `threshold`). Reusa `LLM.embed` + um
`vector_store` do RAG. Após o acerto semântico, aplica um **filtro exato de
escopo** (provider/model/params/método) para alta precisão.

```python
from jangada_ai import LLM, SemanticCache

embedder = LLM("openai", "text-embedding-3-small")
cache = SemanticCache(embedder, threshold=0.85)

llm = LLM("openai", "gpt-4o-mini", cache=cache)
llm.complete("Qual a capital da França?")
llm.complete("Me diga a capital francesa.")   # paráfrase → acerta o cache
```

### Calibre o `threshold` por modelo de embedding

A escala de cosseno **varia muito entre modelos** — não existe um número mágico:

| Modelo de embedding | Paráfrase (≈) | Pergunta distinta (≈) |
|---|---|---|
| `text-embedding-3-small` (OpenAI) | 0.62 | 0.11 |
| `gemini-embedding-001` (Gemini) | 0.90 | 0.49 |

O padrão é `0.85` (meio-termo). **Meça paráfrases vs. perguntas distintas no seu
modelo** e escolha um corte entre as duas distribuições. Threshold alto demais =
cache morto; baixo demais = respostas erradas (falso-positivo). Cache semântico
sem avaliação de falso-positivos é um tiro no pé — amostre alguns acertos e
confira.

## O que **não** é cacheado

- Chamadas com **tools** ou **MCP** (`tools=`/`mcp_servers=`): as tool-calls
  variam e têm efeito colateral.
- **Streaming** (`stream`/`astream`).
- Respostas vindas de **fallback** (só o candidato primário popula o cache).
- Recusas de **guardrail** (o cache fica depois do guard de saída).

`complete`/`parse` e suas versões async compartilham a mesma chave (a `Completion`
cacheada preserva `parsed`, `usage` e `cost`). O cache é **local ao processo** (a
`Completion` é mantida em memória; o vector store é usado só para a similaridade).

## O que mudou na 1.9.0

- **A chave inclui o schema do `parse`**: `parse(p, A)` seguido de `parse(p, B)`
  não devolve mais o objeto do tipo `A`.
- **`SemanticCache` separa por contexto**: system, histórico anterior e partes
  não-texto (imagens) entram no escopo. "sim" ou "continue" em conversas
  diferentes não dão mais hit com a resposta de outra conversa.
- **O hit devolve uma cópia** com `cost=0.0` e `cached=True` — somar custos num
  `Flow`/`Agent` não conta de novo uma chamada que não foi paga, e mexer no objeto
  devolvido não altera o que está no cache.
- **Evicção e expiração limpam o vector store** do `SemanticCache` (antes as
  entradas mortas ficavam lá e derrubavam a taxa de acerto).
- **Erro no cache não derruba a chamada**: falha no `get`/`set` (ex.: o embedder do
  cache semântico tomou rate limit) vira miss, com log em `DEBUG`.
- `ExactCache` e `SemanticCache` são seguros entre threads.

---

# Observabilidade (automática)

A jangada envia as suas chamadas de LLM para a plataforma de observabilidade de
forma **automática** (zero-config): basta configurar o `.env`. Cada chamada vira
uma _observation_ com provider, modelo, tokens, custo, latência, tool calls e as
**capacidades** de IA usadas — enviada em background, sem instrumentar o código.

## Ativar (zero-config)

```bash
# .env
JANGADA_OBSERVABILITY=true
JANGADA_OBSERVABILITY_API_KEY=lobs_xxx        # chave do projeto (dashboard)
# opcional (padrão é a plataforma oficial):
# JANGADA_OBSERVABILITY_ENDPOINT=https://api.jangada.dev.br
```

```python
from jangada_ai import LLM

llm = LLM("openai", "gpt-4o-mini")
resp = llm.complete("Resuma: ...")   # já enviado à plataforma, sozinho
```

Embeddings também são instrumentados automaticamente. Isso inclui os embeddings
gerados por RAG, cache semântico e indexação em lote:

```python
embedder = LLM("openai", "text-embedding-3-small")

with observability_session(name="rag.documents.ingest"):
    vectors = embedder.embed(["primeiro chunk", "segundo chunk"])
```

A observation registra latência, tokens de entrada, custo estimado, a capability
`embeddings` e o array completo de vetores em `output`. O output observado mantém
sempre o formato de lote (`[[...], [...]]`), inclusive quando `embed()` recebe uma
única string. O retorno público não muda: continua sendo um vetor para `str` ou uma
lista de vetores para uma lista de textos.

No Gemini, a contagem segue esta ordem: `usage_metadata` da própria resposta;
`count_tokens()` com o mesmo modelo e lote quando o usage estiver ausente; e,
somente se ambos falharem, estimativa local de aproximadamente 4 caracteres por
token. O preço nunca fica no adapter: `compute_cost()` resolve o modelo pelo
catálogo atualizado de `jangada.dev.br/prices.json`. As observations indicam a
origem em `usageSource` (`provider`, `count_tokens` ou `estimated`) e marcam
`usageEstimated=true` apenas no último fallback.

A flag precisa ser "truthy" (`1`/`true`/`yes`/`on`/`sim`) **e** o token presente;
faltando qualquer um, o modo fica desligado e nada é enviado (custo zero). Falhas
de rede nunca derrubam a aplicação — o envio é best-effort numa thread daemon.

## Encerramento (não perca o último trace)

Como o envio roda numa thread daemon, um **script que termina logo após a última
chamada** correria o risco de perder esse último trace: o interpretador mata as
threads daemon ao sair e o POST morreria no meio. Para evitar isso, a jangada
registra automaticamente um handler de `atexit` que **espera os envios pendentes**
terminarem antes de encerrar (com um prazo total de segurança — não trava o
processo se a rede estiver lenta). Você não precisa fazer nada.

A única exceção é o encerramento **abrupto** (`os._exit()`, `signal` que não passa
pelo `atexit`, ou um `sys.exit()` dentro de contexto que ignora handlers): aí,
chame o flush manualmente antes de sair.

```python
from jangada_ai import LLM, flush_observability

llm = LLM("openai", "gpt-4o-mini")
resp = llm.complete("última pergunta")

flush_observability()   # garante que o trace acima foi enviado
# ... encerramento abrupto ...
```

## Agrupar por lote

Por padrão cada chamada vira um trace próprio, **nomeado pelo script de entrada**
que a gerou (ex.: `python examples/02_multi.py` → `02_multi`) — assim scripts
diferentes ficam distinguíveis no dashboard, em vez de uma parede de traces
iguais. Fora de um script nomeável (REPL, `-c`, `-m`, runners), o nome recua para
o **método** (`complete`, `parse`, `stream`, `embed`…); em nenhum caso aparece
"(sem nome)". Para agrupar várias chamadas de uma request no **mesmo lote** (mesmo
sendo enviadas uma a uma) e dar a ele um nome próprio, abra um escopo com
`observability_session(name=...)`: um id de lote é gerado, todas as chamadas de
dentro o compartilham e o nome do escopo prevalece — o backend as agrupa no mesmo
trace.

```python
from jangada_ai import LLM, observability_session

llm = LLM("openai", "gpt-4o-mini")

with observability_session(name="resumo+tradução", user_id="cliente-123"):
    r1 = llm.complete("Resuma: ...")     # observation no mesmo trace
    r2 = llm.complete("Traduza: ...")    # idem — agrupadas pelo id do lote
```

`observability_session` aceita `id` (reaproveita um id externo), `name`,
`user_id`, `session_id` e `metadata`, e devolve o id do lote. Funciona em código
sync e async (usa `contextvars`).

## Feedback de produção

Capture a reação do usuário final (👍/👎 ou um score) e anexe ao trace com
`feedback()`. Use o **id do lote** devolvido por `observability_session`. É
**best-effort** (nunca derruba a app): devolve `True` se enviou, `False` se faltou
chave ou deu erro de rede.

```python
from jangada_ai import LLM, observability_session, feedback

llm = LLM("openai", "gpt-4o-mini")

with observability_session(name="suporte") as trace_id:
    resp = llm.complete("Como emito uma NF?")

# mais tarde, quando o usuário avaliar a resposta:
feedback(trace_id, 1, comment="resolveu meu problema")   # 👍
# feedback(trace_id, -1, comment="resposta errada")      # 👎
```

O feedback vira um `Score` no trace (origem `api`), aparece no dashboard junto dos
👍/👎 humanos e fecha o loop: um 👎 pode ser **promovido a exemplo** de dataset
(pelo dashboard) e virar caso de regressão nas [evals](eval.md).

## O que é capturado

De cada chamada: `provider`, `model`, `promptTokens`/`completionTokens` (de
`usage`), `costUsd` (de `cost`), latência, o **input** (mensagens ou textos de
embedding; conteúdos muito longos são truncados), o **output** (texto da resposta,
ou quantidade/dimensão dos embeddings) e as **tool calls** que o modelo pediu
(`tools`: id/name/args).

O **input** preserva o histórico de ferramentas de forma auditável: cada
`tool_call` registra o **nome e os argumentos** (`[tool_call consultar_estoque
{"produto": "cabo HDMI"}]`) e cada `tool_result` registra o **conteúdo retornado**
(`[tool_result] {"disponivel": 0, "previsao_dias": 12}`), com marcação de erro
quando aplicável. Assim dá para conferir de onde saiu cada número que o modelo
afirmou — não fica só um marcador vazio. Args e resultados individualmente grandes
são truncados (teto por parte), além do teto global do input.

Cada observation tem um **status**: `OK`, ou **`INCOMPLETE`** quando a resposta
foi cortada por limite de tokens (`finish_reason == "length"`). O motivo de parada
normalizado também é enviado em **`finishReason`**. No dashboard isso vira um badge
(âmbar para incompleto) e entra no filtro de status.

## Capacidades (capabilities)

Cada observation registra **quais capacidades de IA** foram usadas — `tools`,
`mcp`, `a2a`, `vision`, `audio`, `documents`, `rag`, `structured_output`,
`guardrails`, `cache`, `agents`, `embeddings`. No dashboard viram **badges**,
**filtro** e a quebra **Analytics → Uso por capacidade**.

A detecção é automática a partir dos argumentos da chamada: `images=` → `vision`,
`files=` → `documents`, `tools=` → `tools`, `mcp_servers=` → `mcp`, `parse()` →
`structured_output`, `guardrails` → `guardrails`. `tools` também é derivado
quando o modelo pede tool calls.

`embed()`/`aembed()` registram `embeddings` diretamente. Assim, um
`observability_session` que só contenha ingestão RAG deixa de ser um escopo vazio
e aparece normalmente no dashboard.

## No dashboard

Em [app.jangada.dev.br](https://app.jangada.dev.br) você acompanha tudo:

- **Traces e detalhe** — cada lote e suas observations (provider, modelo, tokens,
  custo, latência, tool calls e capacidades), em tabela ou waterfall.
- **Analytics** — custo, chamadas, tokens, taxa de erro e latência (p50/p95/p99),
  com quebra por modelo, provider e capacidade e série temporal por dia.
- **Filtros e exportação** — filtre por modelo, provider, erros, datas,
  userId/sessionId, custo mínimo e capacidade; exporte em CSV/JSON.
- **Live tail** — traces em tempo real, com pausar/retomar.
- **Anomalias** — avisos automáticos quando custo/latência/erro fogem do
  baseline de 7 dias.
- **Alertas** — regras de custo diário ou taxa de erro.
- **Scores** — avaliações por trace (feedback humano ou LLM-as-judge).
- **Orçamento** — teto de custo mensal por projeto, com acompanhamento e projeção.

## Detalhes

- A `api_key` é a chave do projeto, gerada no dashboard e configurada no `.env`.
- Reusar o mesmo id de lote (via `observability_session(id=...)`) **acrescenta**
  observations ao mesmo trace de forma idempotente no backend.
- Os campos de custo/tokens vêm de [Custo e tokens](cost.md).

## O que mudou na 1.9.0

- **Falhas também viram trace.** Quando uma chamada esgota retry e fallback, a lib
  envia uma observation com `status="ERROR"` e o erro (tipo + mensagem, truncado)
  — antes só os sucessos apareciam no dashboard. Para reportar manualmente:
  `jangada_ai.observability.auto_report_error(erro, provider=..., model=...)`.
- **Início real da chamada.** `startedAt` agora marca quando a chamada começou (não
  quando terminou); `auto_report(..., started_at=...)` aceita epoch ou `datetime`.
- **Stream e transcrição reportados.** `stream`/`astream` enviam o texto acumulado
  ao final (capability `streaming`); `transcribe` também reporta (`audio`). Hits de
  cache ganham a capability `cache`.
- **Envio com fila limitada.** Em vez de uma thread por chamada, há uma fila (1000
  eventos) com poucos workers; se o endpoint ficar lento e a fila encher, os
  eventos excedentes são descartados — `dropped_count()` diz quantos. `flush()` e o
  `atexit` esperam a fila esvaziar com prazo.
- **Só HTTPS.** O endpoint (e o do `feedback`) precisa ser `https://` (`http` só em
  localhost), para a chave e os prompts não trafegarem em claro.
- **Output truncado** como o input, e `embed` não envia mais todos os vetores (só
  dimensões e contagem acima de um teto).
- Falhas de envio vão para o log `DEBUG` do logger `jangada_ai` (nunca derrubam a
  chamada).

---

# Erros normalizados

Cada SDK levanta exceções diferentes. A jangada traduz tudo para uma hierarquia
única via `errors.classify()`, com `status_code` quando disponível. Nenhum erro
nativo de SDK escapa da fronteira dos adapters.

```python
from jangada_ai import LLM, errors

try:
    LLM("openai", "modelo-inexistente").complete("oi")
except errors.NotFoundError as e:
    print(e.status_code)   # 404
except errors.LLMError as e:
    print("falha genérica:", e)
```

## Hierarquia (resumo)

Todas herdam de `errors.LLMError`. As principais categorias:

| Erro                 | Origem típica                      | Failover padrão? |
|----------------------|------------------------------------|------------------|
| `RateLimitError`     | 429                                | sim              |
| `TimeoutError`       | timeout de rede                    | sim              |
| `ConnectionError`    | falha de conexão                   | sim              |
| `ServerError`        | 5xx                                | sim              |
| `NotFoundError`      | 404 (modelo/endpoint)              | sim (sem retry)  |
| `OutputValidationError` | `parse()` com JSON fora do schema | sim (sem retry)  |
| `AuthError`          | 401/403                            | **não**          |
| `BadRequestError`    | 400 (params inválidos)             | **não**          |
| `TruncatedError`     | resposta cortada (`max_tokens`)    | **não** (suba `max_tokens`) |

## Conjuntos usados pela política

- `errors.TRANSIENT` — o que dispara **retry com backoff** (rate limit, timeout,
  conexão, 5xx).
- `errors.DEFAULT_FAILOVER` — o que dispara **fallback** (os transitórios + 404 +
  `OutputValidationError`). Saída fora do schema tenta o **próximo modelo** (sem
  repetir o mesmo, que daria o mesmo JSON).
  Não inclui `auth` nem `bad_request` por padrão.

Você pode customizar `retry_on=` e `backoff_on=` por `LLM` — veja
[Retry e fallback](retry-fallback.md).

## O que mudou na 1.9.0

- **Mais status HTTP mapeados**: 408 → `APITimeoutError` e 409 → `ServerError`
  (ambos transitórios, entram no retry); 413 → `BadRequestError`.
- **Recusas e respostas vazias viram erro normalizado.** Resposta sem `choices`,
  recusa da OpenAI no `parse` (`parsed=None`) ou Claude sem `tool_use` no `parse`
  levantam `OutputValidationError` / `ServerError` — que entram no failover — em
  vez de `IndexError`/`StopIteration` crus.
- **Saída cortada no `parse` da OpenAI** (`LengthFinishReasonError`) vira
  `TruncatedError`, com a dica de aumentar `max_tokens`.
- **Gemini com prompt bloqueado por safety** levanta `BadRequestError` (sem retry
  nem fallback — é determinístico), em vez de um `ServerError` "transitório".
- **`classify(e, provider, *, request=False)`**: com `request=True`, um
  `ValidationError` do Pydantic vindo da **montagem da requisição** (SDKs como
  google-genai e mistralai validam o request) vira `BadRequestError`, não
  `OutputValidationError` — não dispara mais failover para outro modelo por um
  `extra=` inválido. Os adapters já usam isso nos caminhos que não são `parse`.

---

# Fluxos e orquestração (Flow e Graph)

A jangada traz duas formas de encadear chamadas, ambas agregando `usage`/`cost`.

## Flow — sequencial

`Flow` encadeia `Step`s: a saída de um vira entrada do próximo.

```python
from jangada_ai import LLM, Flow, Step

llm = LLM("openai", "gpt-4o-mini")

flow = Flow([
    Step("rascunho", "Escreva um parágrafo sobre {{tema}}."),
    Step("revisao",  "Revise e melhore:\n{{rascunho}}"),
])

resultado = flow.run(llm, tema="jangadas do Nordeste")
print(resultado.output)     # saída do último step
print(resultado.cost)       # custo agregado de toda a cadeia
```

Cada `Step` referencia as saídas anteriores pelo nome via template `{{ }}`.

## Graph — roteamento condicional + paralelo

`Graph` permite ramificar (roteamento condicional) e executar nós em paralelo
(core async), juntando os resultados.

```python
from jangada_ai import Graph

# roteamento condicional: escolhe o próximo nó conforme a saída
# paralelo + junção: dispara vários nós e combina as respostas
g = Graph()
# ... defina nós, arestas condicionais e junções ...
res = g.run(...)        # GraphResult agrega usage/cost
```

Veja os exemplos executáveis em
[`examples/graph_example.py`](https://github.com/nerigleston/jangada/blob/master/examples/graph_example.py).

### Fan-out robusto e observável (`parallel`)

`parallel` aceita opções para produção:

```python
g.parallel(
    "analises", branches, join="sintese",
    on_error="skip",        # um ramo que falha não derruba o run (default: "raise")
    max_concurrency=4,      # teto de ramos simultâneos (evita rate limit)
    summarize=True,         # cada ramo resume a própria saída antes do join
)
```

- **`on_error="skip"`**: o ramo que falhar fica ausente do join (string vazia no
  contexto) e vai para `GraphResult.failures` (`{ramo: erro}`). O fan-out entrega o
  parcial em vez de perder o trabalho já pago dos outros ramos. Com `"raise"`
  (padrão) a primeira exceção derruba o run.
- **`max_concurrency=N`**: semáforo — no máximo N ramos rodam ao mesmo tempo.
- **`summarize`**: `True` (instrução padrão) ou uma instrução `str`. Corta o input
  do join (que lê todos os ramos e cresce O(N)); a chamada de resumo entra no
  `usage`/`cost`.

`GraphResult` também expõe **`durations`** (`{nó/ramo: segundos}`) — dá para medir
`max(ramos)` vs `t_join` diretamente, o diagnóstico que importa num fan-out.

## Custo agregado

`FlowResult` e `GraphResult` somam `usage` e `cost` de todas as etapas — útil
para observabilidade. Detalhes em [Custo e tokens](cost.md) e, para inspecionar
passo a passo, [Debug](debug.md).

## O que mudou na 1.9.0

- **`Flow.arun(**contexto)`**: versão assíncrona do `Flow`.
- **`Graph(max_steps=50)`**: teto de nós executados num `run`; um router cíclico
  que nunca termina levanta `RuntimeError` em vez de gastar sem limite.
- **Nó revisitado** (ciclo) ou step com nome repetido não sobrescreve o anterior:
  as visitas aparecem como `nome#2`, `nome#3` em `completions`/`durations`, e
  `usage`/`cost` somam todas. `parsed("nome")` devolve a última visita.
- **Ramos paralelos com `on_error="raise"`**: quando um ramo falha, os irmãos ainda
  pendentes são cancelados (antes continuavam rodando e gastando tokens).
- **Nomes reservados.** Nós, steps, ramos e variáveis de contexto não podem se
  chamar `system`, `history`, `params`, `tools`, `tool_choice`, `files`, `images`,
  `schema`, `prompt`, `mcp_servers` ou `self` — eles colidiam com os argumentos de
  `complete` e causavam erros estranhos. Agora levantam `ValueError` na hora.
- `then()`/`route()` num bloco paralelo levantam erro (antes eram ignorados em
  silêncio); `join` apontando para nó inexistente tem mensagem própria.

## Exemplo

[`examples/graph_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/graph_example.py) — script executável.

---

# Agentes e times

A jangada traz uma camada leve de **orquestração multi-agente** — `Agent` e
`Squad` — construída sobre o que já existe (tool calling, MCP, RAG). Sem
dependência nova: é Python puro compondo a própria lib.

## Agent — um agente com papel e ferramentas

Um `Agent` é um LLM com **papel/objetivo**, opcionalmente com **tools** (funções
que ele executa) e **memória**. Ele roda o loop de tool calling sozinho até a
resposta final.

```python
from jangada_ai import LLM, Agent

def clima(cidade: str) -> str:
    "Retorna o clima de uma cidade."
    return f"ensolarado em {cidade}, 28°C"

meteoro = Agent(
    LLM("openai", "gpt-4o-mini"),
    role="Meteorologista",
    goal="informar o clima de forma clara",
    tools=[clima],
)

res = meteoro.run("Como está o clima em Recife?")
print(res.text)          # o modelo chamou clima("Recife") e respondeu
print(res.cost, res.usage, res.iterations)
```

- `tools=` são **callables** — a função é executada localmente quando o modelo a
  chama, e o resultado volta pro modelo. Podem ser **síncronas** (`def`) ou
  **assíncronas** (`async def`): tools `async def` são aguardadas no laço de
  `arun`. No `run` (síncrono) só use tools síncronas — uma tool async vira um
  resultado de erro orientando a usar `arun`.
- Para um servidor **MCP**, passe `mcp_client=MCPClient(...)` e use **`arun`**
  (async): o agente lista as tools do servidor e as usa junto das suas.
  `mcp_allowed_tools=[...]` restringe quais tools do MCP ficam visíveis.
  `mcp_tools_cache=[...]` pula o `list_tools()` (e o round-trip) toda vez que
  `arun`/`astream` roda — liste uma vez com `await mcp_tools(mcp_client)` e
  passe aqui; sem isso, cada chamada relista as tools do zero.
  `mcp_allowed_tools` continua sendo aplicado por cima de `mcp_tools_cache`
  (filtra a lista já pronta), então dá pra listar sem filtro e restringir
  por instância de `Agent`.
- **`AgentResult.stopped_by_limit`**: `True` quando o loop parou por bater em
  `max_iterations` com `tool_calls` ainda pendentes — nesse caso `text`/
  `messages` NÃO são a resposta final do modelo, são o último passo do loop
  (um `UserWarning` também é emitido). `False` quando o modelo parou de pedir
  tool por conta própria.
- **`on_tool_call`/`on_tool_result`**: callbacks (sync no `run`; sync ou async
  no `arun`) que correm a cada tool call (function ou MCP). `on_tool_call`
  devolvendo `False` **veta** a chamada (o modelo recebe um `tool_result` de
  erro, sem a tool executar). `AgentResult.tool_trace` traz `{"call",
  "result", "is_error"}` de todas as chamadas do turno.

```python
def confirma(call):
    return call.name != "apagar_tudo"   # veta essa tool específica

agente = Agent(llm, role="Operador", tools=[apagar_tudo, listar],
               on_tool_call=confirma, on_tool_result=lambda c, r: print(c.name, r.is_error))
res = agente.run("Liste e apague tudo")
print(res.tool_trace)   # [{"call": ToolCall(...), "result": "...", "is_error": True/False}, ...]
```

```python
async with MCPClient("https://seu-mcp/mcp/") as mcp:
    agente = Agent(llm, role="Operador", mcp_client=mcp)
    print((await agente.arun("Liste os produtos")).text)
```

## Conversa multi-turno (`history=`)

`run`/`arun` aceitam um `history` de turnos anteriores (`list[Message]`) — eles
entram **antes** da nova tarefa, dando continuidade fiel ao diálogo. O
`AgentResult.messages` devolve o histórico completo daquele turno, que você pode
**persistir** (ex.: numa tabela por `conversation_id`) e reinjetar no próximo:

```python
from jangada_ai.message import Message

historico = [
    Message("user", "Quanto gastei em maio?"),
    Message("assistant", "R$ 3.200 em maio."),
]
res = await agente.arun("E no mês anterior?", history=historico)
# guarde res.messages (ou só os pares user/assistant) para o próximo turno
```

> Diferente de um *checkpointer* que serializa o estado inteiro do grafo, aqui o
> histórico é **explícito**: você decide o que persistir e reinjetar. Para
> recall semântico (e não turn-by-turn), use `RAGMemory` (abaixo) — os dois
> compõem.

## Streaming da resposta (`astream`)

`astream` emite a **resposta final** token-a-token. As tool-calls (function tools
e MCP) são resolvidas internamente antes — o protocolo de stream não expõe
`tool_calls`, então não há streaming *durante* a fase de ferramentas; quando o
agente chega à resposta final, ela sai incremental. Sem tools, streama direto.

```python
async for token in agente.astream("Resuma meus gastos do mês"):
    print(token, end="", flush=True)
```

## Agent Card (descoberta / A2A)

`card()` devolve metadados descobríveis do agente no vocabulário do **Agent Card**
do protocolo [A2A](https://a2a-protocol.org) (`name`, `description`, `version`,
`url`, `capabilities`, `skills`):

```python
sofia = Agent(llm, role="Sofia", goal="assistente financeira", tools=[buscar])
sofia.card(url="https://app.exemplo/agents/sofia", version="1.0.0")
# {"name": "Sofia", "capabilities": {"streaming": True, ...},
#  "skills": [{"id": "buscar", "description": "...", "parameters": {...}}], ...}
```

## Servidor A2A (transporte HTTP/JSON-RPC) — `jangada[a2a]`

O extra `jangada[a2a]` expõe um (ou vários) `Agent` como um **servidor A2A** de
verdade, falando o binding JSON-RPC do protocolo: descoberta do Agent Card,
`message/send` (síncrono) e `message/stream` (SSE), com **continuidade por
`contextId`** (o histórico de cada conversa é mantido e reinjetado).

```python
from jangada_ai import LLM, Agent
from jangada_ai.a2a import A2AHandler, build_a2a_app

sofia = Agent(LLM("openai", "gpt-4o-mini"), role="Sofia", goal="finanças", tools=[buscar])
app = build_a2a_app(A2AHandler(sofia, url="https://app.exemplo/a2a"))
# app é um ASGI Starlette — sirva com uvicorn:  uvicorn modulo:app
```

Rotas servidas:

| Rota | O quê |
|------|-------|
| `GET /.well-known/agent-card.json` (e o legado `/.well-known/agent.json`) | Agent Card do agente principal |
| `GET /agents` | catálogo (lista de Agent Cards) |
| `POST /` | JSON-RPC `message/send` (JSON), `message/stream` (SSE), `tasks/get`, `tasks/cancel` |
| `POST /agents/{name}` | idem, para um agente específico do catálogo |

Para um time descobrível, passe uma lista: `build_a2a_app([sofia_h, orcamento_h])`.
A **lógica do protocolo** vive em `A2AHandler` (Python puro, testável sem rede); o
servidor ASGI importa Starlette só aqui. O histórico fica em memória por padrão —
passe `A2AHandler(agent, store=meu_dict)` para plugar persistência.

### Multi-tenant: agente resolvido por request

Em vez de um agente fixo, passe um **`resolver`** que recebe o **contexto da
requisição** (headers/auth) e devolve o `Agent` certo para aquele request — útil
quando o agente é montado por tenant (ex.: tools com closure no `tenant_id` do
JWT). Com `tenant_key`, o histórico fica **isolado por tenant**.

```python
def resolver(ctx):                       # ctx = {"headers": {...}, "auth": "Bearer ..."}
    tenant = (ctx.get("auth") or "").removeprefix("Bearer ")
    return build_sofia(tenant_id=tenant)

handler = A2AHandler(resolver=resolver, name="Sofia",
                     tenant_key=lambda c: c.get("auth", ""))
app = build_a2a_app(handler)             # extrai o contexto do request por padrão
```

Tudo é opcional (o modo fixo `A2AHandler(agent)` segue igual). Os parâmetros:
`resolver` (exclui `agent`), `name` (obrigatório com `resolver`), `description`,
`tenant_key` (isola histórico), e `context_factory=` no `build_a2a_app` (como
montar o contexto a partir do `Request` — troque para validar/decodificar o JWT).
A lib **não** decodifica JWT: o contexto traz os headers crus e o `Authorization`.

## Memória de longo prazo (RAG)

`RAGMemory` dá ao agente memória persistente sobre um `RAG`: antes de responder
ele **recupera** o que é relevante; depois, **guarda** o que aconteceu.

```python
from jangada_ai import LLM, Agent, RAGMemory
from jangada_ai.rag import RAG, InMemoryVectorStore

rag = RAG(LLM("openai", "text-embedding-3-small"), InMemoryVectorStore())
agente = Agent(llm, role="Suporte", memory=RAGMemory(rag, k=3))
```

## Squad — vários agentes colaborando

`Squad` orquestra um time de agentes. Dois processos:

### Sequencial (handoff)

Cada agente roda em ordem e recebe, por padrão, **apenas a saída do agente
anterior** como contexto (não o transcript acumulado):

```python
from jangada_ai import LLM, Agent, Squad

llm = LLM("openai", "gpt-4o-mini")
pesquisador = Agent(llm, role="Pesquisador", goal="levantar fatos")
escritor    = Agent(llm, role="Escritor", goal="escrever um texto claro")

squad = Squad([pesquisador, escritor])
res = squad.run("Escreva um parágrafo sobre jangadas nordestinas.")
print(res.text)            # saída do último agente
print(res.outputs)         # {"Pesquisador": "...", "Escritor": "..."}
```

**Semântica do contexto** (`context=`):

- **`"last"`** (padrão): cada agente recebe só a saída imediatamente anterior. O
  input por salto é ~constante — o custo da cadeia cresce **O(N)**, não O(N²).
- **`"full"`**: cada agente recebe o **transcript acumulado** (todas as saídas
  anteriores, rotuladas por papel). Mais contexto, custo **O(N²)** em cadeias longas.

```python
Squad([pesquisador, escritor], context="full")   # transcript inteiro a cada salto
```

**Observabilidade por agente**: `res.steps` traz uma entrada por agente com
`(role, usage, cost, cost_complete, dt)` — dá para ver o input/custo/latência
crescer (ou não) a cada salto sem desmontar o `Squad` na mão.

### Hierárquico (delegação)

Um agente **gerente** recebe ferramentas `delegar_para_<papel>` geradas
automaticamente a partir dos membros e decide a quem delegar cada subtarefa:

```python
gerente = Agent(llm, role="Gerente", goal="coordenar o time")
squad = Squad([pesquisador, escritor], manager=gerente)
res = squad.run("Produza um resumo sobre o tema X.")
```

Tanto `run` quanto `arun` agregam `usage`/`cost` de todo o time.

## Planejamento

`plan()` decompõe um objetivo numa lista ordenada de tarefas (structured output):

```python
from jangada_ai import plan

for tarefa in plan(llm, "Lançar uma newsletter sobre IA", max_tasks=5):
    print("-", tarefa)
```

## O que mudou na 1.9.0

- **Squad hierárquico de verdade assíncrono.** No `Squad.arun`, a tool de
  delegação é `async` e chama `await membro.arun()` — o event loop não trava e o
  membro mantém `mcp_client` e tools async. O gerente é uma **cópia** do agente
  original, então `on_tool_call` (veto), `on_tool_result`, MCP e memória valem
  também no modo hierárquico.
- **Custo e rastro dos membros.** `SquadResult.usage`/`cost` somam o gerente **e**
  os membros delegados (`cost_complete` só é `True` se todos tiverem preço);
  `outputs` traz a saída de cada membro e `steps` uma entrada por delegação.
  Papéis repetidos viram chaves únicas (`Revisor`, `Revisor#2`).
- **Nomes das tools de delegação** são transliterados (acentos saem), deduplicados
  com sufixo (`_2`, `_3`) e truncados em 64 caracteres.
- **`Agent.astream(prompt, chunk_size=24)`**: sem tools faz streaming real do
  provider; com tools resolve o loop com `acomplete` e emite o texto final em
  pedaços (sem gerar a resposta duas vezes). Ao terminar, `agent.last_stream_result`
  traz o `AgentResult` (usage, cost, `tool_trace`, `stopped_by_limit`).
- **Tools nativas misturadas.** `Agent(llm, tools=[web_search(), minha_funcao])`
  funciona: a tool nativa roda no provider e aparece no `tool_trace` com
  `"server": True` (veja [Tools nativas](native-tools.md)).
- **MCP: allowlist aplicada na execução.** Uma tool MCP que não foi oferecida ao
  modelo (fora de `mcp_allowed_tools`) **não é executada** — volta como
  `tool_result` de erro, mesmo que o modelo invente o nome.
- **Parâmetro `BaseModel` em tools** recebe a instância validada do modelo
  (`jangada_ai.coerce_args`).
- **`plan()`** levanta `ValueError` claro se a resposta vier sem `parsed`;
  `RAGMemory` loga falhas (logger `jangada_ai`) em vez de engoli-las e não grava
  turnos que pararam por limite.

### A2A (card e servidor)

`Agent.card()` segue a spec A2A ≥ 0.3: traz `protocolVersion` (padrão `"0.3.0"`) e
`preferredTransport="JSONRPC"`, aceita `security_schemes=`/`security=`, e os
parâmetros das tools viram `tags` (o campo `skills[].parameters`, fora da spec,
saiu). O `A2AHandler` serve o card em **`/.well-known/agent-card.json`** (e mantém
`/.well-known/agent.json`), limita memória com `max_tasks`/`task_ttl` e
`max_contexts`/`context_ttl` (padrão 1000 itens / 1 h), isola `tasks/get|cancel`
por tenant, devolve `-32002` ao cancelar task já terminada, `-32700`/`-32600`/
`-32602` para JSON inválido/corpo inválido/pergunta vazia, e não expõe a mensagem
da exceção no `-32603` (o detalhe vai para o log).

```python
from jangada_ai import Agent, LLM, Squad, web_search

pesquisador = Agent(LLM("anthropic", "claude-sonnet-5"), role="Pesquisador",
                    tools=[web_search(max_uses=3)])
redator = Agent(LLM("openai", "gpt-5-mini"), role="Redator")
gerente = Agent(LLM("openai", "gpt-5"), role="Gerente")

res = await Squad([pesquisador, redator], manager=gerente).arun("Resuma as novidades do Python 3.14")
print(res.text, res.cost, res.outputs.keys(), len(res.steps))
```

## Como se relaciona com o resto

Não há mágica nem infra nova: `Agent` é o loop de tool calling (como o
[`run_agent`](mcp.md)); a memória é o [RAG](rag.md); o `Squad` hierárquico usa
delegação por tools ([Tools](tools.md)). Você pode trocar o provider de qualquer
agente sem mudar mais nada — a tese da jangada vale também aqui.

---

# Gemini Interactions e agentes (Deep Research)

`GeminiInteractions` é uma camada fina e **específica do Gemini** sobre a
[Interactions API](https://ai.google.dev/gemini-api/docs/interactions) do SDK
`google-genai`. Ela é diferente do `LLM`:

- **Stateful no servidor**: cada chamada devolve um `id`, e a próxima continua a
  conversa com `previous_interaction_id`, sem reenviar o histórico.
- É o único caminho para os **agentes gerenciados** do Google, como o **Deep
  Research**, que planeja, pesquisa na web e escreve um relatório rodando em
  background por vários minutos.

> **Preview.** A Interactions API e os agentes estão em preview no Google:
> nomes de agente e campos mudam com frequência. Exige
> **`google-genai>=2.3`**. Com um SDK mais antigo, a jangada levanta
> `UnsupportedError` pedindo a atualização.

```bash
pip install "jangada-ai[gemini]" "google-genai>=2.3"
```

Use `GEMINI_API_KEY` no ambiente (ou `api_key=`).

```python
from jangada_ai import GeminiInteractions

gi = GeminiInteractions(model="gemini-3.8-flash")
r = gi.create("Quem venceu a Copa do Mundo de 2002?", tools=[{"type": "google_search"}])
print(r.text)
for c in r.citations:
    print("-", c["title"], c["url"])

# continua a conversa no servidor, sem reenviar histórico
r2 = gi.create("E o artilheiro?", previous_interaction_id=r.id)
```

## Quando usar `GeminiInteractions` e quando usar `LLM`

- **`LLM("gemini", ...)`**: o caminho padrão. Troca de provider sem mudar o
  código, retry, fallback, cache, guardrails. Tools nativas do Gemini também
  funcionam por lá (ver [Tools nativas](native-tools.md)).
- **`GeminiInteractions`**: quando você quer o **estado no servidor**
  (`previous_interaction_id`), **agentes gerenciados** (Deep Research) ou tarefas
  longas em **background** com polling. Não tem retry nem fallback: é uma
  camada fina.

## API

```python
GeminiInteractions(api_key=None, *, model=None, vertexai=False, **client_kwargs)
```

| Método (sync / async) | O que faz |
|---|---|
| `create` / `acreate(input, *, model, agent, agent_config, tools, system, previous_interaction_id, store, background, response_format, tool_choice, max_tokens, seed, stop, thinking_level, **extra)` | Cria uma interação (ou dispara um agente) |
| `stream` / `astream(input, ...)` | Mesmo que `create`, produzindo `InteractionEvent` |
| `resume_stream` / `aresume_stream(id, last_event_id=)` | Retoma um stream interrompido |
| `get` / `aget(id)` | Busca o estado atual |
| `cancel` / `acancel(id)` | Cancela |
| `delete` / `adelete(id)` | Apaga |
| `wait` / `await_(target, *, poll_interval=10, timeout=None, on_update=None)` | Polling até sair de `queued`/`in_progress` |
| `run` / `arun(input, *, tools=[...], max_iterations=10, on_tool_call=, on_tool_result=)` | Loop de function calling com execução local |
| `deep_research` / `adeep_research(prompt, *, agent=DEEP_RESEARCH_AGENT, wait=True, ...)` | Dispara o Deep Research em background |

`close()`/`aclose()` fecham o cliente.

### `InteractionResult`

- `id`, `status` (`queued`, `in_progress`, `requires_action`, `completed`,
  `failed`, `cancelled`, `incomplete`, `budget_exceeded`), `done`,
  `requires_action`.
- `text`: texto final.
- `steps`: passos normalizados (dicts: `model_output`, `thought`,
  `function_call`, `google_search_call`...).
- `citations`: lista de dicts `{url, title, start, end}`.
- `function_calls`: chamadas pendentes
  (`InteractionFunctionCall(id, name, args)`, com
  `.result(output, is_error=False)` para montar a resposta).
- `usage`, `cost`, `parsed` (com `response_format`), `errors`, `raw`.
- Preenchidos pelo `run`: `tool_trace`, `iterations`, `stopped_by_limit`,
  `usage_total`, `cost_total`, `cost_complete`.
- `raise_for_status()`: levanta `ProviderError` se terminou em `failed`,
  `cancelled` ou `budget_exceeded`.

## Tools

`tools=` aceita:

- **suas funções** (callables, modelos Pydantic, `Tool`), que viram
  `{"type": "function", ...}`;
- **dicts nativos** da API: `{"type": "google_search"}`, `url_context`,
  `code_execution`, `file_search`, `google_maps`, `mcp_server`...;
- os **`NativeTool`** canônicos da jangada (`web_search()`, `url_context()`,
  `code_execution()`, `file_search(...)`, `google_maps(...)`) e
  `native_tool("gemini", spec)`. `image_generation` e tools de outro provider
  levantam `UnsupportedError`.

### Function calling com execução local (`run`)

O `run` cria a interação, executa localmente as funções que o modelo pedir e
responde com `previous_interaction_id`, até a resposta final ou
`max_iterations`. Uma tool que falha, que não existe ou que é vetada por
`on_tool_call` (devolvendo `False`) vira um resultado com `is_error`, e o modelo
pode reagir.

```python
def cotacao(moeda: str) -> float:
    """Cotação da moeda em reais."""
    return {"USD": 5.4, "EUR": 5.9}.get(moeda.upper(), 0.0)

r = gi.run("Quanto custam 100 dólares em reais?", tools=[cotacao])
print(r.text, [t["name"] for t in r.tool_trace], r.cost_total)
```

Para controlar o loop à mão, use `create` e responda a `r.function_calls`:

```python
r = gi.create("Quanto custam 100 dólares?", tools=[cotacao])
if r.requires_action:
    respostas = [c.result(cotacao(**c.args)) for c in r.function_calls]
    r = gi.create(respostas, previous_interaction_id=r.id, tools=[cotacao])
```

## Structured output

```python
from pydantic import BaseModel

class Resumo(BaseModel):
    titulo: str
    pontos: list[str]

r = gi.create("Resuma a história do frevo.", response_format=Resumo)
print(r.parsed)            # Resumo(...); se não validar, OutputValidationError
```

## Streaming

`stream` produz `InteractionEvent(type, text, status, step, usage, result, ...)`,
com `type` em `created`, `status`, `step_start`, `text`, `thought`, `delta`,
`step_stop`, `completed` (com `.result` final) e `error` (levanta
`ProviderError`).

```python
for ev in gi.stream("Explique RAG em uma frase."):
    if ev.type == "text":
        print(ev.text, end="", flush=True)
```

## Deep Research (background)

```python
rel = gi.deep_research(
    "Panorama do mercado de LLMs open-source em 2026, em 5 tópicos.",
    on_update=lambda x: print("status:", x.status),
    timeout=1800,
)
print(rel.text)
```

- Roda com `background=True` e `store=True` (a API exige `store` com
  background) e faz polling a cada 10 s.
- `wait=False` devolve logo o `InteractionResult` em `queued`/`in_progress`;
  depois use `gi.wait(r)` ou `gi.get(r.id)`.
- `timeout` estourado → `APITimeoutError`. A interação **continua rodando** no
  servidor; você pode retomar com `wait`/`get`.
- O agente padrão é `DEEP_RESEARCH_AGENT` (`"deep-research-preview-04-2026"`).
  Outros agentes vão em `agent=` (ex.: `"deep-research-max-preview-04-2026"`,
  `"antigravity-preview-05-2026"`), com `agent_config=` repassado como veio.
- Leva minutos e consome muitos tokens (100 mil a milhões por tarefa): use com
  consciência de custo.

## Usage e custo

O usage segue o contrato da lib: `input_tokens` (total de input + tokens de uso
de tool), `output_tokens` (output + thoughts), `reasoning_tokens`,
`cache_read_tokens`, `server_tool_requests` e `total_tokens`. O custo é
calculado quando há `model=`. Com `agent=` ele fica `None`, porque não há
tabela de preço por agente.

## Limitações

- `temperature`, `top_p` e `top_k` **não existem** na Interactions API: são
  ignorados (com log em nível debug). `max_tokens`, `seed`, `stop` e
  `thinking_level` funcionam.
- Sem retry, fallback, cache ou guardrails (use o `LLM` para isso).
- `stream` não roda o loop de tools (`run`).
- **Vertex AI** (`vertexai=True`): o SDK roteia, mas a doc do Google ainda não
  confirma a disponibilidade.
- Retenção no Google: 55 dias (pago) ou 1 dia (free).

Relacionado: [Gemini](llm-gemini.md), [Tools nativas](native-tools.md),
[Agentes e times](agents.md).

---

# Guardrails de escopo

Mantêm a LLM **dentro de um domínio** — para que ela não vire um assistente que
responde qualquer coisa — e **barram falas** indesejadas. É uma camada fina de
composição (Python puro, sem dep nova): reusa `Message`/`Completion` e o próprio
`parse` da lib.

Um guardrail intercepta a chamada em dois pontos:

- **input** — antes de chamar o modelo principal (valida o pedido do usuário);
- **output** — depois da resposta (valida o que o modelo respondeu).

Quando barra, o cliente **curto-circuita** e devolve um `Completion` com a
mensagem de recusa (`message=`) — no caso de input, nem chega a gastar o modelo
principal. Com `raise_on_block=True`, levanta `GuardrailError` no lugar.

## `ScopeGuard`

Combina dois mecanismos, do barato ao robusto:

1. **blocklist** (regex/termos) — barra na hora, sem custo nem LLM;
2. **classificador de escopo** (LLM-as-judge) — um `judge=LLM(...)` barato
   decide, via structured output, se o texto pertence ao escopo descrito.

```python
from jangada_ai import LLM, ScopeGuard

guard = ScopeGuard(
    scope=(
        "Suporte do sistema e-Gestor: notas fiscais, financeiro, cadastros. "
        "NÃO responde sobre outros assuntos (receitas, política, código, etc.)."
    ),
    judge=LLM("groq", "llama-3.1-8b"),   # modelo barato/rápido só pra classificar
    block=[r"\bsenha\b", "ignore as instruções"],  # barra na hora, sem LLM
    message="Desculpe, só posso ajudar com assuntos do e-Gestor.",
    check="both",                         # "input" (padrão), "output" ou "both"
)

llm = LLM("openai", "gpt-4o", guardrails=[guard])

llm.complete("como emito uma NF-e?")     # dentro do escopo -> responde normal
llm.complete("me ensina a fazer um bolo")  # fora -> Completion com a recusa
```

A recusa vem como um `Completion` normal (`comp.text == message`), com
`comp.cost is None` e `comp.raw == {"guardrail": "<motivo>"}` para inspeção.

## Parâmetros

| Parâmetro | Função |
|---|---|
| `scope` | Descrição em texto do que é permitido (usada pelo judge). |
| `judge` | `LLM` barato que classifica o escopo. Se omitido, usa o modelo principal. |
| `block` | Lista de regex/termos que barram na hora, sem chamar LLM. |
| `message` | Texto da recusa devolvido quando barra. |
| `check` | `"input"` (padrão), `"output"` ou `"both"`. |
| `instruction` | Sobrescreve a instrução do classificador. |
| `raise_on_block` | `True` levanta `GuardrailError` em vez de recusar. |
| `fail_closed` | Se o judge falhar/for inconclusivo: `True` barra (seguro), `False` (padrão) libera (disponibilidade). |

## Recomendações

- **Use um `judge` separado e barato** (ex.: `llama-3.1-8b`, `gpt-4.1-nano`,
  `gemini-2.5-flash-lite`): a classificação é por chamada, então um modelo pequeno
  derruba o custo. Sem `judge`, o próprio modelo principal classifica (a recursão
  é evitada internamente, mas fica mais caro).
- **A blocklist é grátis**: ponha nela os termos/frases óbvios (vazamento de
  segredo, prompt injection conhecido) e deixe o judge para o julgamento de tema.
- **Custo**: input barrado **não** gasta o modelo principal. Output barrado já
  pagou a geração (a recusa só substitui o texto).

## Onde se aplica

- `complete`/`acomplete` e `parse`/`aparse`: input **e** output.
- `stream`/`astream`: apenas **input** (o guard de output exigiria bufferizar o
  stream inteiro). Se barrado, o stream emite só a mensagem de recusa.

## Guardrail customizado

`ScopeGuard` cobre o caso comum, mas você pode escrever o seu herdando de
`Guardrail` e sobrescrevendo `check_input`/`check_output` (e as versões `a*`),
retornando `GuardResult(ok, reason)`:

```python
from jangada_ai import Guardrail, GuardResult

class MaxLength(Guardrail):
    message = "Mensagem longa demais."
    def __init__(self, limite): self.limite = limite
    def check_input(self, messages, judge):
        texto = " ".join(m.content for m in messages if isinstance(m.content, str))
        return GuardResult(len(texto) <= self.limite, "excedeu o limite")
```

## O que mudou na 1.9.0

- **Seguro em chamadas concorrentes.** O controle que impede o judge de disparar os
  guardrails de novo agora é por contexto (`ContextVar`), não um atributo do `LLM`.
  Antes, com `asyncio.gather` ou threads usando o mesmo `LLM`, uma chamada podia
  **pular todos os guardrails** enquanto outra esperava o judge.
- **`ScopeGuard(check_history=False)`** (padrão): checa só o que chegou depois da
  última fala do assistant — a mensagem nova do usuário e resultados de tool
  novos. Um termo bloqueado lá atrás no histórico não trava mais a conversa para
  sempre, e o loop de um agente não rejulga o histórico inteiro a cada iteração.
  Use `check_history=True` para o comportamento antigo.
- **Resultados de tool** (role `tool`) passam pela blocklist.
- **Recusa na saída preserva o custo**: quando o guard de output barra, a recusa
  traz o `usage`/`cost` da chamada que já foi paga.

---

# Debug passo a passo

Ative `debug=True` para um trace de cada chamada: provider/modelo, params,
retries, fallback, tokens, custo e duração — por agente.

```python
from jangada_ai import LLM

llm = LLM("openai", "gpt-4o-mini", debug=True, name="extrator")
llm.complete("...")
```

O `Debugger` registra os eventos da cadeia:

- `start` — provider, modelo e params da tentativa
- `retry` — erro, número da tentativa e atraso do backoff
- `fallback` — para qual provider/modelo caiu
- `end` — `Completion` resultante e duração em ms
- `error` — erro normalizado quando o candidato esgota as tentativas

O parâmetro `name=` rotula o agente no trace, útil quando há vários `LLM`
diferentes numa mesma orquestração ([Flow/Graph](flows.md)).

Relacionado: [Retry e fallback](retry-fallback.md), [Custo e tokens](cost.md),
[Erros](errors.md).

## Exemplo

[`examples/debug_params_example.py`](https://raw.githubusercontent.com/nerigleston/jangada/master/examples/debug_params_example.py) — script executável.

---

# Estendendo: adicionar um provider

Cada provider é um *adapter* que herda de `Provider` e traduz os tipos
normalizados para o SDK nativo.

## Passos

1. Crie `jangada/providers/<nome>.py` com uma classe que herda de `Provider` e
   implementa os 6 métodos + `_build_client` / `_build_async_client`.
2. Defina `name` e `env_key`.
3. Importe o SDK **só dentro** dos métodos (imports preguiçosos — invariante).
4. Registre em `registry.py` com um loader preguiçoso.
5. Adicione o extra em `pyproject.toml`.

## Contrato (`Provider`)

```python
class Provider:
    name: str
    env_key: str | None

    def _build_client(self) -> Any: ...
    def _build_async_client(self) -> Any: ...
    def complete(self, messages, **opts) -> Completion: ...
    async def acomplete(self, messages, **opts) -> Completion: ...
    def parse(self, messages, schema, **opts) -> Completion: ...
    async def aparse(self, messages, schema, **opts) -> Completion: ...
    def stream(self, messages, **opts) -> Iterator[str]: ...
    def astream(self, messages, **opts) -> AsyncIterator[str]: ...
```

## Atalho para dialeto OpenAI

Se o provider falar `chat.completions` (estilo OpenAI), herde de
`_OpenAICompatible` e só ajuste os atributos:

```python
class MeuProvider(_OpenAICompatible):
    name = "meu"
    env_key = "MEU_API_KEY"
    sdk_module = "meu_sdk"
    sync_class = "Client"
    async_class = "AsyncClient"
    supports_parse_helper = False   # True se tiver .parse() nativo
```

## Invariantes a respeitar

- **Imports preguiçosos**: `import jangada_ai` deve funcionar sem o SDK.
- **Tipos normalizados na fronteira**: fora dos adapters só circula
  `Message`/`Completion`; objetos nativos ficam em `Completion.raw`.
- **Tradução de erro sempre**: envolva chamadas de SDK em `try/except` e
  re-levante via `classify(e, self.name)` — veja [Erros](errors.md).
- **Paridade sync/async**: todo método tem versão `a*`.
- **Quirks por modelo** vão em `profiles.py` — veja [Parâmetros](parameters.md).

## Testes

Use o padrão de `FakeProvider` registrado em runtime; veja
[`tests/conftest.py`](https://github.com/nerigleston/jangada/blob/master/tests/conftest.py).

---

# Melhores práticas

Um apanhado de recomendações para tirar o máximo da jangada em produção. Cada
item aponta para o guia detalhado da capacidade correspondente.

## Provider e modelo

- **Troque por configuração, não por código.** Mantenha `provider`, `model` e
  `api_key` em variáveis de ambiente. A promessa da lib é trocar o provider sem
  mexer no resto — aproveite isso para alternar entre ambientes (dev/prod) e
  fazer testes A/B de modelo.
- **Use o modelo certo para cada tarefa.** Um modelo forte para raciocínio/escrita
  e um modelo barato (ex.: `llama-3.1-8b-instant`) para classificação, triagem e
  judges de guardrail. Não pague por capacidade que a tarefa não exige.
- **Deixe os perfis normalizarem os params.** Não escreva `if model == ...` para
  ajustar `temperature`/`max_tokens`: a camada de perfis já adapta o payload por
  modelo (ver [Parâmetros](parameters.md)).

> **Nota:** o padrão global é `max_tokens=8192`; defina-o explicitamente quando
> o modelo ou a extração exigir um limite diferente.
> Modelos com *thinking* (ex.: Gemini 2.5) podem consumir o orçamento de saída e
> truncar o JSON — um `max_tokens` folgado evita respostas cortadas.

## Structured output

- **Valide sempre contra o schema.** Use `parse()`/`aparse()` com um modelo
  Pydantic; não confie em parsing manual de texto.
- **`comp.parsed` é confiável** — a jangada já faz a coerção: se algum SDK
  devolver `parsed=None` com JSON válido em `.text`, a lib valida o texto pelo
  schema automaticamente (você não precisa do `model_validate_json` manual).
- **JSON fora do schema entra no failover.** Se a saída não casar com o modelo,
  a jangada levanta `errors.OutputValidationError` e **tenta o próximo modelo**
  do `with_fallback` (outro pode acertar). Não repete o mesmo modelo (seria o
  mesmo JSON). Ou seja: o fallback cobre tanto erro de API quanto saída malformada.
- Campos opcionais com `default=None` evitam que o modelo invente valores quando
  o dado não existe. Veja [Structured output](structured-output.md).

## Guardrails

- **Cheque o escopo na entrada, uma vez.** Em agentes com loop de ferramentas,
  não coloque o `ScopeGuard` no LLM que itera — a cada passo o histórico cresce e
  a reavaliação pode recusar uma fala válida. Faça a checagem com um LLM
  "porteiro" antes de iniciar o agente.
- **Use `raise_on_block=True`** quando quiser tratar a recusa no seu fluxo (ex.:
  responder com uma mensagem própria) em vez de devolver a `Completion` de recusa.
- **`fail_closed=True`** em domínios sensíveis: se o judge falhar, barra (seguro).
  Deixe `False` quando disponibilidade importa mais que rigor.
- Reserve a **blocklist** para termos óbvios (regex, custo zero) e deixe o
  **judge** decidir o escopo semântico. Veja [Guardrails](guardrails.md).

## RAG

- **Comece com busca híbrida** (`mode="hybrid"`): combina vetorial e BM25 por RRF
  e costuma superar só vetorial em perguntas com termos exatos.
- **Use `task="document"` ao indexar e `task="query"` ao buscar** — alguns
  providers (Gemini) diferenciam, e o orquestrador `RAG` já faz isso por você.
- **Amplie a recuperação com step-back.** Para perguntas muito específicas, gere
  uma pergunta mais ampla com [`step_back()`](stepback.md) e busque com as duas —
  recupera contexto de fundo que a query original sozinha perderia.
- **Deixe um agente decidir quando recuperar.** Nem toda mensagem precisa de RAG:
  exponha a busca como uma ferramenta e deixe o modelo chamá-la só quando a
  pergunta exigir contexto — saudações e conversa não devem disparar a base.
- Ajuste `k`, `min_score` e `chunk` ao seu conteúdo. Veja [RAG](rag.md).

## Retry e fallback

- **Configure retry para erros transitórios** (429, 5xx, timeouts) com backoff e
  `jitter=True`. Não faça failover em erros de auth (401/403) ou bad request
  (400/422) — trocar de provider não resolve.
- **Encadeie um fallback barato → forte → alternativo** com `with_fallback`. O
  failover acontece antes do primeiro token, inclusive em streaming.
- Veja [Retry e fallback](retry-fallback.md) e [Erros](errors.md).

## Custo e observabilidade

- **Leia `usage` e `cost`** em cada resposta e agregue por fluxo (`Flow`/`Graph`
  somam automaticamente). Sobrescreva preços com `register_price` conforme seu
  contrato.
- **Agrupe chamadas num `Trace`.** Uma requisição do seu serviço = um lote de
  observações (detecção, extração, conferência...), facilitando auditoria e
  diagnóstico. Veja [Custo](cost.md) e [Observabilidade](observability.md).

## Agentes e ferramentas

- **Descreva bem cada ferramenta.** O docstring é o que o modelo lê para decidir
  quando chamá-la — diga o que faz e *quando (não) usar*.
- **Force aritmética via tool.** Para somas/contas, use `tool_choice="required"`
  numa ferramenta de cálculo em vez de confiar na conta "de cabeça" do modelo.
- **Limite `max_iterations`** em agentes para evitar loops longos, e prefira
  `Squad` sequencial quando os papéis são claros (pesquisar → analisar → redigir).
- Veja [Tools](tools.md) e [Agentes](agents.md).

## Transcrição (áudio e vídeo)

- **O Whisper aceita vídeo, mas tem teto de tamanho.** Os endpoints de STT
  (OpenAI/Groq) transcrevem `mp4` direto — extraem a faixa de áudio sozinhos —,
  porém há um limite de **~25 MB** por arquivo. Um vídeo de reunião estoura isso
  com facilidade.
- **Pré-processe a mídia no seu serviço, não na lib.** Antes de transcrever,
  extraia só o áudio e normalize para algo leve (mono, 16 kHz, comprimido) com
  `ffmpeg`. Isso cabe no limite, acelera o upload e padroniza o container.
  Mantenha essa etapa no **seu backend**: a jangada é fina de propósito e não
  embute dependências de sistema (como `ffmpeg`) — ela só repassa os bytes ao
  provider via `Audio.from_bytes(dados, mime, name=...)`.
- **Preserve a extensão no `name`.** O Whisper usa o nome do arquivo para inferir
  o container; ao reprocessar, devolva algo como `reuniao.mp3`.
- **Tenha um fallback.** Se o `ffmpeg` não estiver disponível (ou falhar num
  codec exótico), mande o arquivo original — um `mp4` pequeno ainda transcreve.
- Veja [Transcrição de áudio](audio.md).

## Async e FastAPI

- Use `acomplete`/`aparse`/`astream` em handlers async. Para o pipeline síncrono
  (parsing de arquivos, chamadas em lote), rode em thread
  (`anyio.to_thread.run_sync`) para não bloquear o event loop.
- Em streaming, devolva um `StreamingResponse` consumindo o `astream`. Veja
  [Streaming](streaming.md).

> **Atenção:** não exponha suas chaves no front-end. O navegador deve falar com o
> **seu** backend; é o backend que detém as `api_key` dos providers.
