Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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.
- 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`.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 5 additions & 1 deletion src/dataframeit/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,14 +525,18 @@ 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,
capture_output=True,
text=True,
encoding="utf-8",
cwd=workspace,
env={**os.environ, **env},
env={**catalog_env, **env},
stdin=subprocess.DEVNULL,
check=True,
timeout=_CATALOG_TIMEOUT,
Expand Down
51 changes: 51 additions & 0 deletions tests/test_codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down Expand Up @@ -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)
Expand Down
118 changes: 80 additions & 38 deletions tests/test_codex_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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)
Expand All @@ -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,
Expand All @@ -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"
Expand All @@ -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):
Expand All @@ -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

Expand Down
Loading