From de6d1f10a513193774d62433c18242c59e300fa0 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 17:23:22 -0300 Subject: [PATCH 1/2] Extra codex passa a exigir openai-codex 0.162.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O runtime 0.162.1 liga cinco flags novas por padrão. Lidas no fonte do runtime, nenhuma oferece ferramenta ao modelo nem muda o turno de uma linha, e as cinco entram na lista revisada: api_key_model_discovery só age no catálogo baixado com chave de API, e o app-server do provider usa o catálogo estático de model_catalog_json; browser_annotation_api e in_app_voice são portões dos apps de desktop, sem leitor no runtime; instant_interrupt só interrompe a amostragem quando chega entrada nova ao turno em curso, o que o provider não faz; ultrafast_mode só libera service_tier="ultrafast" pedido pela config, que o provider não define. O runtime passou a omitir o item additional_tools do input quando não há ferramenta, e o request dos modelos com use_responses_lite deixou de trazer lista de ferramentas. O teste do request deixa de exigir uma lista, e um teste novo liga view_image e confere que _tool_lists acha a ferramenta nas duas formas de request, o item additional_tools do input e a chave tools do topo. Closes #226 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- tests/test_codex_runtime.py | 118 ++++++++++++++++++++++++------------ 3 files changed, 82 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 430703e3..bedd7e9d 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.162.1`, que traz o runtime 0.162.1 (#226). 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 turno que falha com um código de erro que o SDK não declara falha só a linha, sem nova tentativa. - 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`. diff --git a/pyproject.toml b/pyproject.toml index 165e9441..7e0d4fd0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -82,7 +82,7 @@ codex = [ "filelock>=3.13", # O SDK fixa a versão exata do runtime, e o runtime decide quais modelos a # conta do ChatGPT alcança: um runtime anterior ao modelo recebe HTTP 400. - "openai-codex==0.160.1", + "openai-codex==0.162.1", ] polars = [ "polars>=1.0", diff --git a/tests/test_codex_runtime.py b/tests/test_codex_runtime.py index 6d103272..b96df500 100644 --- a/tests/test_codex_runtime.py +++ b/tests/test_codex_runtime.py @@ -34,7 +34,9 @@ # um dos dois, vai desligada em `_CODEX_CONFIG_OVERRIDES`. _REVIEWED_ENABLED_FEATURES = frozenset( { + "api_key_model_discovery", "auth_elicitation", + "browser_annotation_api", "browser_use_external", "browser_use_full_cdp_access", "code_mode_host", @@ -51,6 +53,12 @@ "in_app_dictation", "in_app_local_automation", "in_app_updates", + "in_app_voice", + # Interrompe a amostragem quando chega entrada do usuário ao turno em + # curso, como a de `turn/steer`. O provider manda um turno por thread e + # não manda entrada depois dele, e o `turn/interrupt` do prazo cancela o + # turno por outro caminho. + "instant_interrupt", "item_ids", "mentions_v2", "plugin_sharing", @@ -66,6 +74,9 @@ "tool_search_always_defer_mcp_tools", "tool_suggest", "tui_app_server", + # Só libera `service_tier="ultrafast"` quando a config o pede, como + # `fast_mode` faz com `fast`; o provider não configura `service_tier`. + "ultrafast_mode", "unified_exec", "unified_exec_tty", "unified_exec_zsh_fork", @@ -270,13 +281,16 @@ class _Answer(BaseModel): resposta: str -def _use_local_model_provider(monkeypatch, tmp_path, base_url, model="gpt-6-luna"): +def _use_local_model_provider( + monkeypatch, tmp_path, base_url, model="gpt-6-luna", overrides=_CODEX_CONFIG_OVERRIDES +): """Aponta o provider para um endpoint de modelo local, sem conta nem rede. O endpoint entra como provider do runtime ao lado dos demais overrides, e a abertura do backend é a mesma do provider. O runtime manda o request pelo proxy do ambiente mesmo para `127.0.0.1`, e um proxy herdado prenderia o - turno; por isso as variáveis de proxy saem do ambiente. + turno; por isso as variáveis de proxy saem do ambiente. `overrides` troca os + overrides do provider, para o teste que precisa de uma ferramenta oferecida. """ for name in ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY"): monkeypatch.delenv(name, raising=False) @@ -298,7 +312,7 @@ def _use_local_model_provider(monkeypatch, tmp_path, base_url, model="gpt-6-luna monkeypatch.setattr( codex_provider, "_CODEX_CONFIG_OVERRIDES", - (*_CODEX_CONFIG_OVERRIDES, 'model_provider="mock"', f"model_providers.mock={provider}"), + (*overrides, 'model_provider="mock"', f"model_providers.mock={provider}"), ) return LLMConfig( model=model, @@ -312,7 +326,7 @@ def _use_local_model_provider(monkeypatch, tmp_path, base_url, model="gpt-6-luna ) -def _bundled_model_slugs(tmp_path): +def _bundled_models(tmp_path): from codex_cli_bin import bundled_codex_path # noqa: PLC0415 (extra codex opcional) home = tmp_path / "catalog-home" @@ -326,7 +340,38 @@ def _bundled_model_slugs(tmp_path): check=True, timeout=60, ) - return [model["slug"] for model in json.loads(listing.stdout)["models"]] + return json.loads(listing.stdout)["models"] + + +def _request_to_model(tmp_path, monkeypatch, slug, overrides=_CODEX_CONFIG_OVERRIDES): + """Corpo, em bytes, do primeiro request que uma linha manda ao modelo `slug`.""" + run_path = tmp_path / slug + run_path.mkdir() + handler = type("Handler", (_RecordingProvider,), {"bodies": []}) + server = HTTPServer(("127.0.0.1", 0), handler) + threading.Thread(target=server.serve_forever, daemon=True).start() + port = server.server_address[1] + config = _use_local_model_provider( + monkeypatch, run_path, f"http://127.0.0.1:{port}/v1", model=slug, overrides=overrides + ) + + try: + with ( + codex_provider.open_codex_backend(config, _Answer, "Responda: {texto}") as backend, + pytest.warns(UserWarning, match="não-recuperável"), + pytest.raises(ProviderError, match="mock"), + ): + backend.invoke("oi") + finally: + server.shutdown() + server.server_close() + monkeypatch.undo() + + assert handler.bodies, slug + encoding, raw = handler.bodies[0] + assert encoding is None + assert json.loads(raw)["model"] == slug + return raw def test_request_to_model_offers_only_reviewed_tools(tmp_path, monkeypatch): @@ -336,48 +381,45 @@ def test_request_to_model_offers_only_reviewed_tools(tmp_path, monkeypatch): campos de ferramentas. O catálogo liga o code mode e os sub-agentes para modelos como o `gpt-6-luna`; o catálogo de `_write_model_catalog` desliga o primeiro, e `agents.enabled=false`, os segundos. + + Sem ferramenta, o request dos modelos com `use_responses_lite` não traz + lista alguma, porque o runtime só põe o item `additional_tools` no `input` + quando há o que oferecer. Que a busca de `_tool_lists` alcança o lugar onde + o runtime põe as ferramentas, quando há, é o que prova + `test_tool_lists_find_tool_offered_by_runtime`. """ - slugs = _bundled_model_slugs(tmp_path) + slugs = [model["slug"] for model in _bundled_models(tmp_path)] assert slugs for slug in slugs: - run_path = tmp_path / slug - run_path.mkdir() - handler = type("Handler", (_RecordingProvider,), {"bodies": []}) - server = HTTPServer(("127.0.0.1", 0), handler) - threading.Thread(target=server.serve_forever, daemon=True).start() - port = server.server_address[1] - config = _use_local_model_provider( - monkeypatch, run_path, f"http://127.0.0.1:{port}/v1", model=slug - ) - - try: - with ( - codex_provider.open_codex_backend(config, _Answer, "Responda: {texto}") as backend, - pytest.warns(UserWarning, match="não-recuperável"), - pytest.raises(ProviderError, match="mock"), - ): - backend.invoke("oi") - finally: - server.shutdown() - server.server_close() - monkeypatch.undo() - - assert handler.bodies, slug - encoding, raw = handler.bodies[0] - assert encoding is None - request = json.loads(raw) - assert request["model"] == slug - # Sem nenhuma lista, o runtime mudou onde põe as ferramentas, e a - # igualdade com o conjunto revisado, vazio, passaria sem provar nada. - tool_lists = _tool_lists(request, []) - assert tool_lists, slug - assert _offered_tools(tool_lists) == _REVIEWED_MODEL_TOOLS, slug + raw = _request_to_model(tmp_path, monkeypatch, slug) + assert _offered_tools(_tool_lists(json.loads(raw), [])) == _REVIEWED_MODEL_TOOLS, slug # O nome da ferramenta de sub-agente não aparece em parte alguma do corpo. # `collaboration` não serve para essa busca, porque aparece no texto das # instruções. assert b"spawn_agent" not in raw, slug +def test_tool_lists_find_tool_offered_by_runtime(tmp_path, monkeypatch): + """Com uma ferramenta ligada, `_tool_lists` a acha no request. + + Sem este controle, a igualdade com o conjunto revisado, vazio, passaria + também se o runtime mudasse o lugar onde põe as ferramentas. O controle + tira o override de `view_image`, que oferece uma ferramenta, e roda um + modelo de cada forma de request: com `use_responses_lite`, as ferramentas + vão num item `additional_tools` do `input`; sem, na chave `tools` do topo. + """ + override = "features.view_image=false" + assert override in _CODEX_CONFIG_OVERRIDES + overrides = tuple(item for item in _CODEX_CONFIG_OVERRIDES if item != override) + one_per_shape = {} + for model in _bundled_models(tmp_path): + one_per_shape.setdefault(bool(model.get("use_responses_lite")), model["slug"]) + assert one_per_shape + for slug in one_per_shape.values(): + raw = _request_to_model(tmp_path, monkeypatch, slug, overrides=overrides) + assert "view_image" in _offered_tools(_tool_lists(json.loads(raw), [])), slug + + def _pipes_fechados(processo): return processo.stdout.closed and processo.stderr.closed From f974cf2dd8c897a6eb8dbb90d32eff60b0ac9588 Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Wed, 14 Oct 2026 18:14:02 -0300 Subject: [PATCH 2/2] =?UTF-8?q?Cat=C3=A1logo=20do=20Codex=20lido=20sem=20C?= =?UTF-8?q?ODEX=5FAPI=5FKEY=20e=20teste=20do=20c=C3=B3digo=20de=20erro=20d?= =?UTF-8?q?esconhecido?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Com CODEX_API_KEY no ambiente, a flag api_key_model_discovery do runtime 0.162.1 faz o `codex debug models` baixar o catálogo da API com essa chave, enquanto o app-server dos turnos a ignora e usa o auth.json. A variável sai do ambiente do `debug models`, e o catálogo passa a vir da mesma credencial dos turnos. Novo teste prende a classificação do turno que falha com código ou variante de CodexErrorInfo que o SDK não declara: ProviderError, uma única thread, sem nova tentativa nem abort. --- CHANGELOG.md | 2 +- src/dataframeit/codex.py | 6 ++++- tests/test_codex.py | 51 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44adeeee..c117e434 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.162.1`, que traz o runtime 0.162.1 (#226). 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 turno que falha com um código de erro que o SDK não declara falha só a linha, sem nova tentativa. +- O extra `codex` passa a exigir `openai-codex==0.162.1`, que traz o runtime 0.162.1 (#226). 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 turno que falha com um código de erro que o SDK não declara falha só a linha, sem nova tentativa. Com `CODEX_API_KEY` no ambiente, o catálogo de modelos passa a ser lido com a credencial do `auth.json`, a mesma dos turnos, e não com essa chave, que o app-server ignora. - 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`. diff --git a/src/dataframeit/codex.py b/src/dataframeit/codex.py index abd12966..f0f8009a 100644 --- a/src/dataframeit/codex.py +++ b/src/dataframeit/codex.py @@ -525,6 +525,10 @@ def _write_model_catalog(workspace: Path, env: dict[str, str]) -> Path: for override in _CODEX_CONFIG_OVERRIDES: command += ["--config", override] command += ["debug", "models"] + # Com CODEX_API_KEY no ambiente, `debug models` baixa o catálogo da API com + # essa chave, que o app-server do turno ignora: o catálogo seria de uma + # credencial e os turnos, de outra. + catalog_env = {name: value for name, value in os.environ.items() if name != "CODEX_API_KEY"} try: listing = subprocess.run( # noqa: S603 (binário empacotado, argumentos fixos) command, @@ -532,7 +536,7 @@ def _write_model_catalog(workspace: Path, env: dict[str, str]) -> Path: text=True, encoding="utf-8", cwd=workspace, - env={**os.environ, **env}, + env={**catalog_env, **env}, stdin=subprocess.DEVNULL, check=True, timeout=_CATALOG_TIMEOUT, diff --git a/tests/test_codex.py b/tests/test_codex.py index 5e2c010b..add15485 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -854,6 +854,35 @@ def test_app_server_le_o_catalogo_gravado_no_runtime(self, codex_sdk, monkeypatc assert model[field] == value assert not catalog_path.exists() + def test_catalogo_e_lido_sem_codex_api_key(self, codex_sdk, monkeypatch, tmp_path): + # O app-server do turno ignora CODEX_API_KEY; com ela, `debug models` leria + # o catálogo da API com essa chave, e não com a credencial dos turnos. + sdk, sdk_types, _ = codex_sdk + source_home = tmp_path / "source-home" + source_home.mkdir() + (source_home / "auth.json").write_text("{}") + monkeypatch.setenv("CODEX_HOME", str(source_home)) + monkeypatch.setenv("CODEX_API_KEY", "sk-falsa") + monkeypatch.setenv("DATAFRAMEIT_VARIAVEL_DO_USUARIO", "mantida") + client = as_context_manager(MagicMock(spec=sdk.Codex)) + client.account.return_value = sdk_types.GetAccountResponse(requiresOpenaiAuth=False) + listing = subprocess.CompletedProcess( + args=[], returncode=0, stdout='{"models": []}', stderr="" + ) + + with ( + patch("dataframeit.codex.subprocess.run", return_value=listing) as run, + patch.object(sdk, "Codex", return_value=client), + open_codex_backend(make_config(), SampleModel, "{texto}") as backend, + ): + isolated_home = backend._workspace.parent / "home" + + ambiente = run.call_args.kwargs["env"] + assert "CODEX_API_KEY" not in ambiente + assert ambiente["DATAFRAMEIT_VARIAVEL_DO_USUARIO"] == "mantida" + assert ambiente["CODEX_HOME"] == os.fspath(isolated_home) + assert run.call_args.args[0][-2:] == ["debug", "models"] + @pytest.mark.parametrize( ("stdout", "message"), [ @@ -1495,6 +1524,28 @@ def test_failed_turn_row_scoped_codes_fail_only_the_row(self, codex_sdk, tmp_pat assert not isinstance(exc_info.value, (ProviderTransientError, ProviderAbortError)) assert client.thread_start.call_count == 1 + # O SDK aceita código e variante fora do protocolo que conhece: o código vira + # membro novo de CodexErrorInfoValue, e a variante fica como dict cru. + @pytest.mark.parametrize( + "codigo", ["codigoNovo", {"variantNova": {}}], ids=["codigo", "variante"] + ) + def test_codigo_de_erro_desconhecido_falha_so_a_linha(self, codex_sdk, tmp_path, codigo): + _, _, generated = codex_sdk + backend, client, _, turn = initialized_backend(tmp_path, codex_sdk) + info = generated.CodexErrorInfo.model_validate(codigo) + turn.stream.side_effect = lambda: as_stream( + make_result(codex_sdk, error_info=info, message="novidade") + ) + + with ( + pytest.warns(UserWarning, match="não-recuperável"), + pytest.raises(ProviderError, match="novidade") as exc_info, + ): + backend.invoke("texto") + + assert not isinstance(exc_info.value, (ProviderTransientError, ProviderAbortError)) + assert client.thread_start.call_count == 1 + def test_failed_turn_http_401_is_definitive(self, codex_sdk, tmp_path): _, _, generated = codex_sdk backend, client, _, turn = initialized_backend(tmp_path, codex_sdk)