diff --git a/CHANGELOG.md b/CHANGELOG.md index 430703e3..956a99ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,7 +20,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR - `dataframeit()` declara o tipo de retorno pelo tipo da entrada: DataFrame ou Series do polars devolve DataFrame do polars, e DataFrame ou Series do pandas, `list` e `dict` devolvem DataFrame do pandas. Antes, o retorno era a união dos dois, e o checker de tipos recusava `resultado["coluna"].tolist()` (#155). - O piso do pydantic sobe para 2.12. Na 2.11, uma `default_factory` que lê outro campo é chamada com os dados validados vazios quando esse campo falhou, e a retomada levantava o `KeyError` dela em vez do erro de campo incompatível (#154). - Os extras `polars` e `all` passam a exigir `polars>=1.0`. No 0.20, há versões que convertem a coluna de texto com um valor de outro tipo e põem nulo no lugar dele, sem erro, e versões em que o `to_pandas()` de uma coluna `Object`, pelo qual ela volta na retomada, devolve bytes no lugar dos objetos ou falha (#218). -- O extra `codex` passa a exigir `openai-codex==0.159.2`, que traz o runtime 0.159.2. Com o runtime 0.137, que o SDK anterior fixava, a conta do ChatGPT recebia HTTP 400 nos modelos lançados depois dele, como o `gpt-6-luna`, embora o catálogo da conta os anunciasse. O `effort` é conferido contra os valores que o SDK declara, agora com `max` e `ultra`, porque o enum do SDK novo aceita qualquer texto. +- O extra `codex` passa a exigir `openai-codex==0.160.1`, que traz o runtime 0.160.1. Com o runtime 0.137, que o SDK anterior fixava, a conta do ChatGPT recebia HTTP 400 nos modelos lançados depois dele, como o `gpt-6-luna`, embora o catálogo da conta os anunciasse. O `effort` é conferido contra os valores que o SDK declara, agora com `max` e `ultra`, porque o enum do SDK novo aceita qualquer texto. - No provider `codex`, os códigos de erro `rateLimitExceeded` e `flexUnavailable` do runtime levantam `ProviderOverloadedError`, e a linha é repetida como no HTTP 429. - No provider `codex`, as flags `sleep_tool` e `view_image` do runtime ficam desligadas, e as ferramentas de pausa e de leitura de imagem local deixam de ser oferecidas ao modelo. - No provider `codex`, a flag `unbounded_connection_retries` do runtime fica desligada. Ligada, uma falha de conexão deixa o turno esperando para sempre; desligada, a linha falha com `ProviderTransientError` e é repetida conforme `max_retries`. @@ -39,6 +39,7 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR ### Corrigido +- Uma resposta recusada pela validação do modelo Pydantic deixa de ser lida como erro de credencial quando o texto tem números como 401 ou 403, comuns em texto jurídico ("art. 401 da CLT", "Súmula 403"). No `claude_code`, a falha de parsing ou de validação levanta `ProviderRejectedOutputError` e ganha nova tentativa; antes, a linha falhava na primeira tentativa com a instrução de reconfigurar a autenticação. No `codex`, a mensagem amigável do `ProviderOutputError` é decidida pela classe, e não manda mais rodar `codex login`. Nos dois, a mensagem gravada em `_error_details` leva o caminho e a regra de cada erro, sem o valor recusado. `is_rate_limit_error` decide pela classe para todo `ProviderError` e para o `ProviderOutputError`, e entre eles só o `ProviderOverloadedError` conta como rate limit: um '429' no texto, como no resumo de um erro de validação do `codex` ou no campo `result` que o SDK do Claude Code devolve num erro, deixa de reduzir os workers. No `codex`, o erro JSON-RPC de código -32001, com que o app-server recusa o pedido quando a fila de entrada está cheia, levanta `ProviderOverloadedError`: a linha é repetida e, se falhar em todas as tentativas, reduz os workers, como no HTTP 429. Antes, esse erro falhava a linha na primeira tentativa, sem reduzir os workers. O erro do `codex`, de turno ou JSON-RPC, que traz '429 Too Many Requests' só no texto deixa de reduzir os workers, porque o rate limit é reconhecido pelo código do erro (#192). - No provider `codex`, a saída do backend e o encerramento pelo prazo fecham também os pipes de stdout e stderr do app-server, que o `close` do SDK deixa abertos; antes, os dois ficavam para o coletor de lixo, com um `ResourceWarning: unclosed file` cada. Quando o app-server morre no meio de uma linha, `dataframeit()` deixa de levantar `BrokenPipeError` ao encerrá-lo e devolve as linhas já processadas, com a linha abortada e as seguintes sem status (#225). - Com entrada polars e `resume=True`, a coluna de um campo `int` do modelo é lida com os inteiros e os nulos do polars. Antes, o nulo de uma linha pendente levava a coluna a float: o inteiro acima de 2**53 voltava arredondado, a coluna saía `Float64` e as respostas novas saíam em float. Uma coluna `UInt64` com valor acima do `Int64` deixa de ser acusada como incompatível e sai `Object`, como na execução sem interrupção. A coluna `Int64` sem nulo do campo `int` também chega em inteiros Python, e um modelo com `strict=True` deixa de acusar como incompatível a linha processada, que o `int64` do numpy não validava (#222). - Com entrada polars, o campo cuja anotação junta `date`, `datetime` ou `time` a outro tipo, na união, na tupla ou na lista ou tupla de união, sai `Object`, com os valores que a entrada pandas devolveria e o aviso das colunas que o polars recusa. Antes, a coluna era tipada pelo primeiro valor, sem erro nem aviso: em `Union[datetime, date]` com a data primeiro, a hora sumia; em `Union[date, int]` e `tuple[date, int]`, o inteiro 5 virava `1970-01-06`; em `Union[time, int]`, o inteiro fora do intervalo de um dia virava nulo ou meia-noite, conforme a versão do polars; em `list[Union[date, datetime]]`, a hora sumia (#228). diff --git a/README.md b/README.md index 79276407..810f4dba 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Field and class names are arbitrary; the examples in the [`example/`](https://gi - **Multiple providers**: OpenAI, Google Gemini, Anthropic and Groq with their own extras, any other LangChain provider (Cohere, Mistral, Vertex AI, Bedrock, Azure) by installing its package, plus Claude Code and Codex through their official SDKs - **Multiple input types**: pandas or Polars DataFrame and Series, list, dict -- **Structured output**: Pydantic validation; a rejected answer goes back to the model with the error +- **Structured output**: Pydantic validation; with the LangChain providers, a rejected answer goes back to the model with the error - **Resilience**: Automatic retry with exponential backoff, and periodic checkpoints (`batch_size` + `checkpoint_path`) to resume long runs - **Performance**: Parallel processing that halves workers on rate limits, configurable rate limiting - **Web search**: Tavily or Exa, per field or per group of fields, with conditional fields (`condition`, `depends_on`) diff --git a/README.pt-BR.md b/README.pt-BR.md index 65c60e48..1b62aaed 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -76,7 +76,7 @@ Os nomes de classes e campos são arbitrários: os notebooks de [`example/`](exa - **Múltiplos providers**: OpenAI, Google Gemini, Anthropic e Groq com extras próprios, qualquer outro provider do LangChain (Cohere, Mistral, Vertex AI, Bedrock, Azure) instalando o pacote dele, além de Claude Code e Codex pelos SDKs oficiais - **Múltiplos tipos de entrada**: DataFrame e Series do pandas ou do Polars, list, dict -- **Saída estruturada**: Validação com Pydantic; uma resposta recusada volta ao modelo com o erro +- **Saída estruturada**: Validação com Pydantic; nos providers do LangChain, uma resposta recusada volta ao modelo com o erro - **Resiliência**: Retry automático com backoff exponencial e checkpoints periódicos (`batch_size` + `checkpoint_path`) para retomar execuções longas - **Performance**: Processamento paralelo que reduz os workers pela metade em rate limit, rate limiting configurável - **Busca web**: Tavily ou Exa, por campo ou por grupo de campos, com campos condicionais (`condition`, `depends_on`) diff --git a/docs/en/guides/error-handling.md b/docs/en/guides/error-handling.md index d4715922..88360b8c 100644 --- a/docs/en/guides/error-handling.md +++ b/docs/en/guides/error-handling.md @@ -68,13 +68,15 @@ The wait before attempt `n + 1` is `min(base_delay × 2^(n-1), max_delay)`, plus - **Connection error**: Network issues - **5xx errors**: Server problems -### Response rejected by validation (retry with the error) +### Response rejected by validation - **Validation error**: the response fails the Pydantic model, including custom validators (`model_validator`, `field_validator`) - **Parsing error**: the response is not valid JSON or did not come in the structured format With LangChain providers, the next attempt sends the model the rejected response and the list of errors, each with the field path and the rejected value, asking it to answer again fixing those points. Repeating the same prompt tends to repeat the same error. When the response is not JSON, the request carries the parser's error text. +With `claude_code`, the next attempt repeats the request, without the list of errors. With `codex`, a response that fails validation raises `ProviderOutputError`, and the row fails without a new attempt. In both, a validation failure writes to `_error_details` the path and the rule of each error, without the rejected value; path and rule still carry response text when Pydantic puts it there, such as a custom validator's message and, with `claude_code`, a dict key. A `claude_code` parsing failure only records that the response holds no valid JSON. + With OpenAI, the SDK validates the response inside the call and raises before returning the message; the rejected response and its tokens are read from the HTTP response attached to the error. When no raw response is available, the correction request goes with the prompt, carrying the rejected values. When a later attempt succeeds, tokens from the rejected ones are added to `_input_tokens` and `_output_tokens`, because they are billed too; if every attempt fails, the row gets status `error`, no token count, and `_error_details` names the field and the rule of each error. ### Permanent Errors (no retry) diff --git a/docs/en/guides/faq.md b/docs/en/guides/faq.md index 3d68233a..05e5e743 100644 --- a/docs/en/guides/faq.md +++ b/docs/en/guides/faq.md @@ -53,7 +53,7 @@ With `resume=False`, if the model columns are already in the DataFrame and `repr ### Many 429 errors (rate limit) -The provider rejected requests for exceeding its rate. dataframeit already halves the workers on each 429 and retries with backoff, but the stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). +The provider rejected requests for exceeding its rate. dataframeit retries with backoff and, when the row fails after all attempts, halves the workers. With `codex`, the rate limit and the app-server overload are recognized by the error code or status, without reading the message; with the LangChain providers, the rate limit is recognized by the 429 status or the error class and, when the error carries neither, by the message text. The stable fix is to lower `parallel_requests` or raise `rate_limit_delay`. The calculation is in [Performance](performance.md#calculating-ideal-delay). With web search, the search provider's limit is usually the tightest; see [Web Search](web-search.md#rate-limits-and-parallel-processing). ### How much will it cost? diff --git a/docs/en/guides/performance.md b/docs/en/guides/performance.md index ba42d9bb..4c0b040d 100644 --- a/docs/en/guides/performance.md +++ b/docs/en/guides/performance.md @@ -25,7 +25,7 @@ result = dataframeit( ### Auto-reduction on Rate Limits -When a 429 error is detected, DataFrameIt automatically reduces workers: +When a row fails after all attempts with a rate limit error, DataFrameIt halves the workers: ``` Start: 10 workers @@ -34,6 +34,8 @@ Rate limit detected → 2 workers Rate limit detected → 1 worker ``` +With `codex`, the rate limit and the app-server overload are recognized by the error code or status, without reading the message. With the LangChain providers, the rate limit is recognized by the 429 status or the error class and, when the error carries neither, by the message text; overload (529, 503) does not reduce the workers. + !!! info "Safety" Workers are only **reduced**, never automatically increased. This prevents unexpected costs. diff --git a/docs/en/reference/exceptions.md b/docs/en/reference/exceptions.md index ed68bc95..8ecdaaec 100644 --- a/docs/en/reference/exceptions.md +++ b/docs/en/reference/exceptions.md @@ -50,7 +50,7 @@ ValueError | `ProviderError` | No | Definitive provider error, such as rejected authentication or exhausted `claude_code` budget | | `ProviderTransientError` | Yes | Network or service failure that usually goes away on its own | | `ProviderOverloadedError` | Yes | Provider overload or HTTP 429 | -| `ProviderRejectedOutputError` | Yes | The response failed validation against the Pydantic model; the new attempt sends the model the rejected response and the error | -| `ProviderOutputError` | No | The provider finished without a usable response, such as a `codex` turn with no content | +| `ProviderRejectedOutputError` | Yes | The response failed validation against the Pydantic model. With LangChain providers, the new attempt sends the model the rejected response and the error; with `claude_code`, it repeats the request | +| `ProviderOutputError` | No | The provider finished without a usable response, such as a `codex` turn with no content or with a response that fails validation against the Pydantic model | Errors from providers via LangChain do not use these classes. They are classified by the type LangChain itself declares, by the HTTP status and by the message. The details are in [Error Handling](../guides/error-handling.md). diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index d3f39096..223063ba 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -68,13 +68,15 @@ A espera antes da tentativa `n + 1` é `min(base_delay × 2^(n-1), max_delay)`, - **Erro de conexão**: Problemas de rede - **Erro 5xx**: Problemas no servidor -### Resposta recusada pela validação (retry com o erro) +### Resposta recusada pela validação - **Erro de validação**: a resposta não passa no modelo Pydantic, inclusive nos validadores próprios (`model_validator`, `field_validator`) - **Erro de parsing**: a resposta não é JSON válido ou não veio no formato estruturado Nos providers do LangChain, a tentativa seguinte leva ao modelo a resposta recusada e a lista de erros, cada um com o caminho do campo e o valor recusado, e pede que ele responda de novo corrigindo esses pontos. Repetir o mesmo prompt tende a repetir o mesmo erro. Quando a resposta não é JSON, o pedido leva o texto do erro do parser. +No `claude_code`, a tentativa seguinte repete o pedido, sem a lista de erros. No `codex`, a resposta que não passa na validação levanta `ProviderOutputError`, e a linha falha sem nova tentativa. Nos dois, a falha de validação grava em `_error_details` o caminho e a regra de cada erro, sem o valor recusado; caminho e regra ainda trazem texto da resposta quando o Pydantic o põe ali, como a mensagem de um validador próprio e, no `claude_code`, a chave de um dict. A falha de parsing do `claude_code` grava só que a resposta não contém JSON válido. + Na OpenAI, o SDK valida a resposta dentro da chamada e levanta o erro antes de devolver a mensagem; a resposta recusada e os tokens são lidos da resposta HTTP anexada ao erro. Quando nenhuma resposta bruta está disponível, o pedido de correção vai junto do prompt, com os valores recusados. Quando uma tentativa seguinte dá certo, os tokens das recusadas entram em `_input_tokens` e `_output_tokens`, porque também são cobrados; se todas falham, a linha fica com status `error`, sem contagem de tokens, e `_error_details` diz o campo e a regra de cada erro. ### Erros Permanentes (sem retry) diff --git a/docs/guides/faq.md b/docs/guides/faq.md index 946210a0..a1c7fbfd 100644 --- a/docs/guides/faq.md +++ b/docs/guides/faq.md @@ -53,7 +53,7 @@ Com `resume=False`, se as colunas do modelo já estão no DataFrame e `reprocess ### Muitos erros 429 (rate limit) -O provider recusou requisições por excesso de taxa. O dataframeit já reduz os workers pela metade a cada 429 e tenta de novo com backoff, mas a solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). +O provider recusou requisições por excesso de taxa. O dataframeit tenta de novo com backoff e, quando a linha falha depois de todas as tentativas, reduz os workers pela metade. No `codex`, o rate limit e a sobrecarga do app-server são reconhecidos pelo código ou pelo status do erro, sem ler a mensagem; nos providers do LangChain, o rate limit é reconhecido pelo status 429 ou pela classe do erro e, quando o erro não traz nenhum dos dois, pelo texto da mensagem. A solução estável é baixar `parallel_requests` ou aumentar `rate_limit_delay`. A conta está em [Performance](performance.md#calculando-o-delay-ideal). Com busca web, o limite do provedor de busca costuma ser o mais apertado; ver [Busca Web](web-search.md#rate-limits-e-processamento-paralelo). ### Quanto vai custar? diff --git a/docs/guides/performance.md b/docs/guides/performance.md index 61c98e57..fb0b129d 100644 --- a/docs/guides/performance.md +++ b/docs/guides/performance.md @@ -25,7 +25,7 @@ resultado = dataframeit( ### Auto-redução em Rate Limits -Quando detecta erro 429, o DataFrameIt reduz workers automaticamente: +Quando uma linha falha depois de todas as tentativas com erro de rate limit, o DataFrameIt reduz os workers pela metade: ``` Início: 10 workers @@ -34,6 +34,8 @@ Rate limit detectado → 2 workers Rate limit detectado → 1 worker ``` +No `codex`, o rate limit e a sobrecarga do app-server são reconhecidos pelo código ou pelo status do erro, sem ler a mensagem. Nos providers do LangChain, o rate limit é reconhecido pelo status 429 ou pela classe do erro e, quando o erro não traz nenhum dos dois, pelo texto da mensagem; a sobrecarga (529, 503) não reduz os workers. + !!! info "Segurança" Workers são apenas **reduzidos**, nunca aumentados automaticamente. Isso evita custos inesperados. diff --git a/docs/reference/exceptions.md b/docs/reference/exceptions.md index b49db5a6..713193eb 100644 --- a/docs/reference/exceptions.md +++ b/docs/reference/exceptions.md @@ -50,7 +50,7 @@ ValueError | `ProviderError` | Não | Erro definitivo do provider, como autenticação recusada ou orçamento do `claude_code` esgotado | | `ProviderTransientError` | Sim | Falha de rede ou do serviço que costuma passar sozinha | | `ProviderOverloadedError` | Sim | Sobrecarga ou HTTP 429 do provider | -| `ProviderRejectedOutputError` | Sim | A resposta não passou na validação do modelo Pydantic; a nova tentativa leva ao modelo a resposta recusada e o erro | -| `ProviderOutputError` | Não | O provider terminou sem resposta utilizável, como um turno do `codex` sem conteúdo | +| `ProviderRejectedOutputError` | Sim | A resposta não passou na validação do modelo Pydantic. Nos providers do LangChain, a nova tentativa leva ao modelo a resposta recusada e o erro; no `claude_code`, repete o pedido | +| `ProviderOutputError` | Não | O provider terminou sem resposta utilizável, como um turno do `codex` sem conteúdo ou com resposta que não passa na validação do modelo Pydantic | Erros dos providers via LangChain não usam essas classes. Eles são classificados pelo tipo que a própria LangChain declara, pelo status HTTP e pela mensagem. Os detalhes estão em [Tratamento de Erros](../guides/error-handling.md). diff --git a/src/dataframeit/claude_code.py b/src/dataframeit/claude_code.py index d7a854c3..73239b4c 100644 --- a/src/dataframeit/claude_code.py +++ b/src/dataframeit/claude_code.py @@ -11,11 +11,15 @@ import json from typing import TYPE_CHECKING, Any, TypeVar +from pydantic import ValidationError + from .errors import ( ProviderError, ProviderOverloadedError, + ProviderRejectedOutputError, ProviderTransientError, retry_with_backoff, + validation_error_summary, ) from .llm import LLMConfig, _parse_usage_metadata, build_prompt from .utils import parse_json @@ -234,9 +238,20 @@ def _call() -> dict: msg = "Claude Code SDK retornou resposta vazia" raise ValueError(msg) - # Parse e validação - parsed = parse_json(response_text) - validated = pydantic_model.model_validate(parsed) + # A recusa sai como ProviderRejectedOutputError, transitória pela classe. + # A mensagem não leva o valor recusado, mas na falha de validação o + # caminho e a regra trazem texto da resposta quando o Pydantic o põe + # ali (ver validation_error_summary). + try: + parsed = parse_json(response_text) + except ValueError as err: + msg = "Falha no parsing da resposta: o texto não contém um JSON válido" + raise ProviderRejectedOutputError(msg) from err + try: + validated = pydantic_model.model_validate(parsed) + except ValidationError as err: + msg = f"Falha na validação da resposta: {validation_error_summary(err)}" + raise ProviderRejectedOutputError(msg) from err usage = _usage_from_sdk(getattr(result, "usage", None), spent) return {"data": validated.model_dump(), "usage": usage} diff --git a/src/dataframeit/codex.py b/src/dataframeit/codex.py index 0e2ed1bd..abd12966 100644 --- a/src/dataframeit/codex.py +++ b/src/dataframeit/codex.py @@ -30,6 +30,7 @@ ProviderTransientError, ProviderUsageLimitError, retry_with_backoff, + validation_error_summary, ) from .llm import LLMConfig, build_prompt @@ -43,6 +44,11 @@ # Status HTTP de rate limit e início da faixa de erro do servidor. _HTTP_TOO_MANY_REQUESTS = 429 _HTTP_SERVER_ERROR_MIN = 500 +# Código JSON-RPC com que o app-server do Codex recusa um pedido quando a fila de +# entrada está cheia, com a mensagem "Server overloaded; retry later." e sem +# `data`. O `is_retryable_error` do SDK procura a sobrecarga só no `data` e não o +# reconhece. +_RPC_SERVER_OVERLOADED = -32001 _ALLOWED_MODEL_KWARGS = frozenset({"effort", "timeout"}) _AUTH_LOCK_SUFFIX = ".dataframeit.lock" @@ -735,7 +741,7 @@ def _invoke_once(self, prompt: str, usage_total: dict[str, int]) -> dict: try: validated = self._pydantic_model.model_validate_json(final_response) except ValidationError as err: - msg = f"Resposta do Codex não corresponde ao schema: {err}" + msg = f"Resposta do Codex não corresponde ao schema: {validation_error_summary(err)}" raise ProviderOutputError(msg) from err return {"data": validated.model_dump(), "usage": dict(usage_total) or None} @@ -1003,14 +1009,19 @@ def _raise_turn_error(error: TurnError | None) -> NoReturn: def _raise_classified_sdk_error(error: Exception) -> NoReturn: from openai_codex import is_retryable_error # noqa: PLC0415 (extra codex opcional) - from openai_codex.errors import TransportClosedError # noqa: PLC0415 (extra codex opcional) + from openai_codex.errors import ( # noqa: PLC0415 (extra codex opcional) + JsonRpcError, + TransportClosedError, + ) message = f"{type(error).__name__}: {error}" # Com o app-server encerrado, toda linha seguinte falharia do mesmo jeito. if isinstance(error, (TransportClosedError, BrokenPipeError)): msg = f"O app-server do Codex encerrou: {message}" raise ProviderAbortError(msg) from error - if is_retryable_error(error): + if is_retryable_error(error) or ( + isinstance(error, JsonRpcError) and error.code == _RPC_SERVER_OVERLOADED + ): raise ProviderOverloadedError(message) from error raise ProviderError(message) from error diff --git a/src/dataframeit/core.py b/src/dataframeit/core.py index c53f5b25..cb0b19fa 100644 --- a/src/dataframeit/core.py +++ b/src/dataframeit/core.py @@ -1077,7 +1077,11 @@ def dataframeit( # noqa: C901, PLR0912, PLR0913, PLR0915, PLR0917 (API pública Com provider='codex', aceita somente effort. parallel_requests: Número de requisições paralelas (padrão: 1 = sequencial). Se > 1, processa múltiplas linhas simultaneamente. - Ao detectar erro de rate limit (429), o número de workers é reduzido automaticamente. + Quando uma linha falha depois de todas as tentativas com erro de rate limit, + o número de workers cai pela metade. No codex, o rate limit e a sobrecarga do + app-server são reconhecidos pelo código ou pelo status, sem ler a mensagem; + nos providers do LangChain, o rate limit é reconhecido pelo status 429 ou pela + classe do erro e, sem nenhum dos dois, pelo texto da mensagem. Dica: use track_tokens=True para ver métricas de throughput (RPM, TPM) e calibrar. use_search: Se True, habilita busca web antes de processar. Padrão: False. search_provider: Provedor de busca web a usar. Opções: diff --git a/src/dataframeit/errors.py b/src/dataframeit/errors.py index d7b00574..d613a7fe 100644 --- a/src/dataframeit/errors.py +++ b/src/dataframeit/errors.py @@ -22,6 +22,8 @@ if TYPE_CHECKING: from collections.abc import Callable, Iterable + from pydantic import ValidationError + from .search import SearchProvider @@ -52,7 +54,9 @@ class ProviderOverloadedError(ProviderTransientError): class ProviderRejectedOutputError(ProviderTransientError, ValueError): - """Resposta recusada pela validação do modelo; a tentativa seguinte pede a correção. + """Resposta recusada pela validação do modelo; ganha nova tentativa. + + Nos providers do LangChain, a tentativa seguinte pede a correção. É transitória por classe, e não pela mensagem: um número como '404' no texto analisado faria a classificação por texto tratá-la como erro HTTP definitivo. @@ -81,6 +85,28 @@ class ProviderOutputError(ValueError): """Resposta definitiva incompatível com o contrato de saída.""" +# Teto de erros de validação listados por resposta recusada, na mensagem da +# exceção e no pedido de correção do LangChain. +MAX_REJECTED_RULES = 20 + + +def validation_error_summary(error: ValidationError) -> str: + """Caminho e regra de cada erro de validação, sem o valor recusado (`input`). + + A mensagem vai para `_error_details`, e o valor recusado levaria dado da + linha para a coluna. O caminho e a regra ainda trazem texto da resposta + quando o Pydantic o põe ali: chave de dict, tag de união discriminada, + mensagem de validador próprio. Por isso a classificação das exceções que + carregam este resumo decide pela classe, e não pelo texto. + """ + rules = [ + f"{'.'.join(str(part) for part in item.get('loc', ())) or '(resposta inteira)'}: " + f"{item.get('msg', '')}" + for item in error.errors()[:MAX_REJECTED_RULES] + ] + return f"{error.error_count()} erro(s): {'; '.join(rules)}" + + # Erros considerados recuperáveis (transientes) RECOVERABLE_ERRORS = ( # Timeouts e deadlines @@ -403,14 +429,23 @@ def get_friendly_error_message( # noqa: C901, PLR0911 (uma saída por categoria Returns: Mensagem de erro amigável com instruções de como resolver. """ + # As duas classes de resposta recusada decidem pela classe: a mensagem pode + # trazer texto da resposta no caminho e na regra de um erro de validação + # (ver validation_error_summary), e um número como '401' na chave de um dict + # não pode virar erro de credencial. if isinstance(error, ProviderRejectedOutputError): - # Decidida pela classe: a mensagem não traz o texto analisado, mas um - # número como '401' no caminho de um campo não pode virar erro de chave. return ( "RESPOSTA RECUSADA PELA VALIDAÇÃO DO MODELO PYDANTIC\n" f"{error}\n" - "As tentativas levaram o erro de volta ao modelo, sem sucesso. Veja as regras " - "do modelo que falharam; se o texto é ambíguo, a instrução do campo pode ajudar." + "Nenhuma tentativa produziu resposta válida. Veja as regras do modelo que " + "falharam; se o texto é ambíguo, a instrução do campo pode ajudar." + ) + if isinstance(error, ProviderOutputError): + return ( + "RESPOSTA FORA DO CONTRATO DE SAÍDA\n" + f"{error}\n" + "O provider terminou sem uma resposta que sirva ao modelo Pydantic, e a linha " + "não é tentada de novo." ) error_str = f"{type(error).__name__}: {error}".lower() @@ -789,7 +824,14 @@ def is_rate_limit_error(error: Exception) -> bool: """ if isinstance(error, ProviderOverloadedError): return True - if isinstance(error, ProviderTransientError): + # Rate limit do pacote é só ProviderOverloadedError. Os demais ProviderError e + # o ProviderOutputError decidem pela classe, sem ler o texto: a mensagem deles + # pode trazer texto do modelo, como o resumo da validação e o campo result do + # SDK no erro do claude_code, e um '429' ali seria tomado por rate limit. O + # codex classifica o rate limit na origem, pelo código de erro do turno ou do + # JSON-RPC, e o erro que traz "429 Too Many Requests" só no texto não reduz os + # workers. + if isinstance(error, (ProviderError, ProviderOutputError)): return False if _ModelRateLimitError is not None and isinstance(error, _ModelRateLimitError): return True diff --git a/src/dataframeit/llm.py b/src/dataframeit/llm.py index 8e44993e..fdc6b49a 100644 --- a/src/dataframeit/llm.py +++ b/src/dataframeit/llm.py @@ -10,7 +10,12 @@ from langchain_core.exceptions import OutputParserException from pydantic import BaseModel, ValidationError -from .errors import ProviderRejectedOutputError, retry_with_backoff +from .errors import ( + MAX_REJECTED_RULES, + ProviderRejectedOutputError, + retry_with_backoff, + validation_error_summary, +) if TYPE_CHECKING: from collections.abc import Callable @@ -370,7 +375,6 @@ def _raw_payload(raw_message: object) -> tuple[dict | None, str]: # Tetos do pedido de correção: ele volta ao modelo a cada recusa, e um esquema # grande pode gerar dezenas de erros com trechos longos. -_MAX_ERRORS = 20 _MAX_INPUT_CHARS = 300 _MAX_ERROR_TEXT = 2000 @@ -395,7 +399,7 @@ def _validation_error_of( def _format_validation_error(error: ValidationError) -> str: """Uma linha por erro, com o caminho do campo e o valor recusado.""" lines = [] - for detail in error.errors()[:_MAX_ERRORS]: + for detail in error.errors()[:MAX_REJECTED_RULES]: location = ".".join(str(part) for part in detail.get("loc", ())) or "(resposta inteira)" value = json.dumps(detail.get("input"), ensure_ascii=False, default=str) if len(value) > _MAX_INPUT_CHARS: @@ -423,11 +427,7 @@ def _request_correction( validation_error = _validation_error_of(error, pydantic_model, payload) if validation_error is not None: detail = _format_validation_error(validation_error) - rules = [ - f"{'.'.join(str(part) for part in item.get('loc', ())) or '(resposta inteira)'}: {item.get('msg', '')}" - for item in validation_error.errors()[:_MAX_ERRORS] - ] - summary = f"{validation_error.error_count()} erro(s): {'; '.join(rules)}" + summary = validation_error_summary(validation_error) elif error is not None: detail = str(error)[:_MAX_ERROR_TEXT] summary = f"resposta fora do esquema ({type(error).__name__})" diff --git a/tests/test_claude_code.py b/tests/test_claude_code.py index 2ede3803..dddc7f69 100644 --- a/tests/test_claude_code.py +++ b/tests/test_claude_code.py @@ -19,7 +19,9 @@ from dataframeit.errors import ( ProviderError, ProviderOverloadedError, + ProviderRejectedOutputError, ProviderTransientError, + get_friendly_error_message, is_rate_limit_error, is_recoverable_error, validate_provider_dependencies, @@ -563,3 +565,50 @@ def test_resposta_so_com_espacos_e_erro(sdk_falso): with pytest.raises(ValueError, match="Claude Code SDK retornou resposta vazia"): _chamar() + + +# Números como '401' ou '403' aparecem em texto jurídico ("art. 401 da CLT", +# "Súmula 403"); a resposta recusada não pode virar erro de credencial. +@pytest.mark.parametrize( + "resposta", + [ + '{"sentimento": "positivo", "confianca": "art. 401 da CLT"}', + '{"sentimento": "Súmula 403 do STJ", "confianca": 0.9', + ], +) +def test_resposta_recusada_com_numero_no_texto_e_tentada_de_novo(sdk_falso, resposta): + sdk_falso["mensagens_do_sdk"] = [ + _AssistantMessageFalso([_TextBlockFalso(resposta)]), + _ResultMessageFalso(usage=None), + ] + + with ( + pytest.warns(UserWarning, match=r"Tentativa 1/2 falhou \(ProviderRejectedOutputError\)"), + pytest.raises(ProviderRejectedOutputError) as erro, + ): + _chamar(replace(_config_claude_code(), max_retries=2)) + + assert len(sdk_falso["opcoes"]) == 2 + mensagem = str(erro.value) + assert "401" not in mensagem + assert "403" not in mensagem + amigavel = get_friendly_error_message(erro.value, "claude_code") + assert "AUTENTICAÇÃO" not in amigavel + assert "PERMISSÃO" not in amigavel + + +def test_resposta_recusada_leva_caminho_e_regra_sem_o_valor(sdk_falso): + sdk_falso["mensagens_do_sdk"] = [ + _AssistantMessageFalso( + [_TextBlockFalso('{"sentimento": "positivo", "confianca": "valor secreto"}')] + ), + _ResultMessageFalso(usage=None), + ] + + with pytest.raises(ProviderRejectedOutputError) as erro: + _chamar() + + assert str(erro.value) == ( + "Falha na validação da resposta: 1 erro(s): confianca: " + "Input should be a valid number, unable to parse string as a number" + ) diff --git a/tests/test_codex.py b/tests/test_codex.py index fbe349f5..5e2c010b 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -16,10 +16,12 @@ from datetime import time as time_type from ipaddress import IPv4Address, IPv6Address from pathlib import Path +from types import SimpleNamespace from typing import Annotated, Any, Literal from unittest.mock import MagicMock, call, patch from uuid import UUID +import pandas as pd import pytest from pydantic import UUID4, AnyUrl, BaseModel, ConfigDict, Field, RootModel from pydantic.errors import PydanticInvalidForJsonSchema @@ -30,12 +32,14 @@ _AppServerStop, _build_schema, _close_client, + _raise_classified_sdk_error, _to_strict_json_schema, _toml_string, _turn_timeout, _validate_config, open_codex_backend, ) +from dataframeit.core import dataframeit from dataframeit.errors import ( CODEX_FILE_AUTH_LOGIN_COMMAND, ProviderAbortError, @@ -1332,6 +1336,27 @@ def test_final_response_is_validated_directly_by_pydantic_json( assert client.thread_start.call_count == 1 + def test_number_in_rejected_response_is_not_a_credential_error(self, codex_sdk, tmp_path): + response = '{"sentimento": "Súmula 403 do STJ", "confianca": "art. 401"}' + backend, _, _, _ = initialized_backend( + tmp_path, codex_sdk, make_result(codex_sdk, response=response) + ) + + with ( + pytest.warns(UserWarning, match="não-recuperável"), + pytest.raises(ProviderOutputError) as error, + ): + backend.invoke("texto") + + message = str(error.value) + assert "Súmula" not in message + assert "art. 401" not in message + assert "confianca: Input should be a valid number" in message + friendly = get_friendly_error_message(error.value, "codex") + assert "AUTENTICAÇÃO" not in friendly + assert "PERMISSÃO" not in friendly + assert "login" not in friendly + @pytest.mark.parametrize( ("response", "status", "message"), [ @@ -1395,24 +1420,23 @@ def test_failed_turn_overload_uses_real_protocol_error(self, codex_sdk, tmp_path assert client.thread_start.call_count == 2 thread.read.assert_not_called() - def test_failed_turn_internal_server_error_retries_without_rate_limit( - self, codex_sdk, tmp_path - ): + @pytest.mark.parametrize("code", ["internal_server_error", "thread_rollback_failed"]) + def test_failed_turn_transient_code_retries_without_rate_limit(self, codex_sdk, tmp_path, code): _, _, generated = codex_sdk backend, client, _, turn = initialized_backend(tmp_path, codex_sdk) - internal = generated.CodexErrorInfo( - root=generated.CodexErrorInfoValue.internal_server_error + info = generated.CodexErrorInfo(root=generated.CodexErrorInfoValue[code]) + turn.stream.side_effect = lambda: as_stream( + make_result(codex_sdk, error_info=info, message="internal failure") ) - turn.stream.side_effect = [ - as_stream(make_result(codex_sdk, error_info=internal, message="internal failure")), - as_stream(make_result(codex_sdk)), - ] - with pytest.warns(UserWarning, match="Tentativa 1/2"): - result = backend.invoke("texto") + with ( + pytest.warns(UserWarning, match="Tentativa 1/2"), + pytest.raises(ProviderTransientError, match="internal failure") as exc_info, + ): + backend.invoke("texto") - assert result["_retry_info"]["retries"] == 1 assert client.thread_start.call_count == 2 + assert is_rate_limit_error(exc_info.value) is False def test_failed_turn_http_429_is_overload_and_retries(self, codex_sdk, tmp_path): _, _, generated = codex_sdk @@ -1706,16 +1730,18 @@ def test_falha_http_sem_status_ou_do_servidor_e_transitoria(self, codex_sdk, tmp ) ) ) - turn.stream.side_effect = [ - as_stream(make_result(codex_sdk, error_info=caiu, message="stream caiu")), - as_stream(make_result(codex_sdk)), - ] + turn.stream.side_effect = lambda: as_stream( + make_result(codex_sdk, error_info=caiu, message="stream caiu") + ) - with pytest.warns(UserWarning, match="Tentativa 1/2"): - result = backend.invoke("texto") + with ( + pytest.warns(UserWarning, match="Tentativa 1/2"), + pytest.raises(ProviderTransientError, match="stream caiu") as exc_info, + ): + backend.invoke("texto") - assert result["_retry_info"]["retries"] == 1 assert client.thread_start.call_count == 2 + assert is_rate_limit_error(exc_info.value) is False def test_codigo_de_erro_fora_dos_transitorios_e_definitivo(self, codex_sdk, tmp_path): _, _, generated = codex_sdk @@ -1783,6 +1809,91 @@ def test_app_server_encerrado_interrompe_a_execucao(self, codex_sdk, tmp_path, o assert client.thread_start.call_count == 1 + # O app-server recusa com o código JSON-RPC -32001, sem `data`, o pedido que + # chega com a fila de entrada cheia. A classificação lê o código, e não a + # mensagem: o -32000 com o mesmo texto continua definitivo. + def test_fila_cheia_do_app_server_e_sobrecarga(self, codex_sdk): + from openai_codex.errors import ( # noqa: PLC0415 (SDK carregado pelo fixture) + map_jsonrpc_error, + ) + + erro = map_jsonrpc_error(-32001, "Server overloaded; retry later.", None) + + with pytest.raises(ProviderOverloadedError, match="Server overloaded") as exc_info: + _raise_classified_sdk_error(erro) + + assert is_rate_limit_error(exc_info.value) is True + + @pytest.mark.parametrize("codigo", [-32000, -32002]) + def test_outro_codigo_json_rpc_com_texto_de_sobrecarga_e_definitivo(self, codex_sdk, codigo): + from openai_codex.errors import ( # noqa: PLC0415 (SDK carregado pelo fixture) + map_jsonrpc_error, + ) + + erro = map_jsonrpc_error(codigo, "Server overloaded; retry later.", None) + + with pytest.raises(ProviderError) as exc_info: + _raise_classified_sdk_error(erro) + + assert not isinstance(exc_info.value, ProviderTransientError) + assert is_rate_limit_error(exc_info.value) is False + + def test_codigo_fora_de_erro_json_rpc_nao_e_lido(self, codex_sdk): + class ErroComCodigo(RuntimeError): + code = -32001 + + with pytest.raises(ProviderError) as exc_info: + _raise_classified_sdk_error(ErroComCodigo("falhou")) + + assert not isinstance(exc_info.value, ProviderTransientError) + + def test_fila_cheia_reduz_workers_e_repete_a_linha(self, codex_sdk, tmp_path, monkeypatch): + from openai_codex.errors import ( # noqa: PLC0415 (SDK carregado pelo fixture) + map_jsonrpc_error, + ) + + backend, client, _, _ = initialized_backend(tmp_path, codex_sdk) + pedidos = [] + trava = threading.Lock() + + def fila_cheia(**_): + with trava: + pedidos.append(1) + raise map_jsonrpc_error(-32001, "Server overloaded; retry later.", None) + + client.thread_start.side_effect = fila_cheia + + @contextlib.contextmanager + def backend_falso(*_args): + yield backend + + monkeypatch.setattr("dataframeit.codex.open_codex_backend", backend_falso) + # O evento de rate limit é limpo na hora, sem o timer de 5 s. + monkeypatch.setattr( + "dataframeit.core.threading.Timer", + lambda _intervalo, funcao: SimpleNamespace(start=funcao), + ) + entrada = pd.DataFrame({"texto": ["a", "b", "c", "d"]}) + + with pytest.warns(UserWarning, match="Reduzindo workers de 4 para 2") as avisos: + saida = dataframeit( + entrada, SampleModel, "Analise: {texto}", provider="codex", parallel_requests=4 + ) + + mensagens = [str(aviso.message) for aviso in avisos] + assert [m for m in mensagens if m.startswith("Rate limit detectado")] == [ + "Rate limit detectado! Reduzindo workers de 4 para 2.", + "Rate limit detectado! Reduzindo workers de 2 para 1.", + ] + # make_config dá duas tentativas a cada linha. + assert ( + sum(m.startswith("Tentativa 1/2 falhou (ProviderOverloadedError)") for m in mensagens) + == 4 + ) + assert len(pedidos) == 8 + assert saida["_dataframeit_status"].tolist() == ["error"] * 4 + assert all("ProviderOverloadedError" in detalhe for detalhe in saida["_error_details"]) + def test_stream_sem_conclusao_e_transitorio(self, codex_sdk, tmp_path): backend, client, _, turn = initialized_backend(tmp_path, codex_sdk) sem_conclusao = make_result(codex_sdk)[:-1] diff --git a/tests/test_errors.py b/tests/test_errors.py index 9116f44a..823a81f5 100644 --- a/tests/test_errors.py +++ b/tests/test_errors.py @@ -1,12 +1,22 @@ """Testes para inferência de provider em dataframeit.errors.""" +from types import SimpleNamespace + import pytest +from pydantic import ValidationError, create_model +from dataframeit.claude_code import _raise_for_result_error from dataframeit.errors import ( + MAX_REJECTED_RULES, + ProviderError, + ProviderOutputError, + ProviderRejectedOutputError, ProviderTransientError, _infer_provider_info, get_friendly_error_message, + is_rate_limit_error, retry_with_backoff, + validation_error_summary, ) @@ -136,3 +146,88 @@ def funcao(): assert len(chamadas) == 2 assert resultado["_retry_info"]["retries"] == 1 + + +# O resumo de um erro de validação pode trazer texto da resposta, como a +# mensagem de um validador próprio; um número nele não pode decidir a categoria +# do erro. +_RESUMO_COM_NUMERO = ( + "Resposta do Codex não corresponde ao schema: 1 erro(s): " + "fundamento: Value error, art. {numero} da CLT não consta da lista" +) + + +def test_resposta_fora_do_contrato_nao_vira_erro_de_credencial(): + erro = ProviderOutputError(_RESUMO_COM_NUMERO.format(numero=401)) + + amigavel = get_friendly_error_message(erro, "codex") + + assert amigavel.startswith("RESPOSTA FORA DO CONTRATO DE SAÍDA") + assert "login" not in amigavel + + +def test_mensagem_da_resposta_fora_do_contrato_traz_o_detalhe_do_erro(): + erro = ProviderOutputError(_RESUMO_COM_NUMERO.format(numero=401)) + amigavel = get_friendly_error_message(erro, "codex") + + assert str(erro) in amigavel + assert "não é tentada de novo" in amigavel + + +def test_resumo_da_validacao_conta_todos_os_erros_e_lista_ate_o_teto(): + campos = {f"campo_{i}": (int, ...) for i in range(MAX_REJECTED_RULES + 5)} + # Campos por **kwargs, que o ty não casa com as sobrecargas de create_model. + modelo = create_model("MuitosCampos", **campos) # ty: ignore[no-matching-overload] + with pytest.raises(ValidationError) as recusa: + modelo.model_validate({}) + + resumo = validation_error_summary(recusa.value) + + assert resumo.startswith(f"{MAX_REJECTED_RULES + 5} erro(s): ") + assert resumo.count("Field required") == MAX_REJECTED_RULES + + +# Só os providers do LangChain levam o erro de volta ao modelo; o claude_code +# repete o pedido, e a mensagem da classe vale para os dois caminhos. +def test_mensagem_da_resposta_recusada_nao_promete_retorno_ao_modelo(): + erro = ProviderRejectedOutputError("Falha na validação da resposta: 1 erro(s): campo: regra") + + amigavel = get_friendly_error_message(erro, "claude_code") + + assert str(erro) in amigavel + assert "ao modelo" not in amigavel.lower() + + +def test_numero_no_resumo_da_validacao_nao_conta_como_rate_limit(): + erro = ProviderOutputError(_RESUMO_COM_NUMERO.format(numero=429)) + + assert is_rate_limit_error(erro) is False + + +# Rate limit do pacote é só ProviderOverloadedError. O ProviderError do +# claude_code leva na mensagem o campo result do SDK, que pode trazer texto do +# modelo, e um "429" ali não pode reduzir os workers. +def test_429_no_result_do_claude_code_nao_conta_como_rate_limit(): + resultado = SimpleNamespace( + is_error=True, + subtype="error_max_turns", + api_error_status=None, + errors=None, + result="Fundamento: art. 429 do CPC", + ) + with pytest.raises(ProviderError) as falha: + _raise_for_result_error(resultado) + + assert type(falha.value) is ProviderError + assert "art. 429 do CPC" in str(falha.value) + assert is_rate_limit_error(falha.value) is False + + +# Nenhum ProviderError é lido pelo texto, nem o turno do codex que falha só com +# "429 Too Many Requests" na mensagem, sem código classificável. +@pytest.mark.parametrize( + "mensagem", + ["Turno Codex falhou: 429 Too Many Requests", "Claude Code falhou: art. 429 do CPC"], +) +def test_provider_error_com_429_na_mensagem_nao_conta_como_rate_limit(mensagem): + assert is_rate_limit_error(ProviderError(mensagem)) is False