From 2f4c1e24df4d178e241e4c6e0fe7ee01c3596b51 Mon Sep 17 00:00:00 2001 From: Konstantin Sivakov Date: Tue, 7 Jul 2026 17:47:44 +0200 Subject: [PATCH 1/3] scrub user input messages from secrets --- anton/core/session.py | 22 ++++++++++++++++++++++ anton/tools.py | 8 ++++++-- tests/test_scrubbing.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_tools.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 2 deletions(-) diff --git a/anton/core/session.py b/anton/core/session.py index f2b718ad..043c654c 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -99,6 +99,26 @@ def _extract_datasources(tool_call: ToolCall) -> List[str]: seen.add(m.group(1).lower()) return list(seen) + +def _scrub_user_input(user_input: str | list[dict]) -> str | list[dict]: + """Scrub credential values from an inbound user message. + + Applied at the `turn`/`turn_stream` entry, before the first + `_append_history`, so a secret pasted into chat never reaches model + context, episodic memory, or the trace sinks downstream of the LLM + gateway (Langfuse). Only text blocks are scrubbed; image and file + blocks carry no scrubbable text. + """ + if isinstance(user_input, str): + return scrub_credentials(user_input) + return [ + {**b, "text": scrub_credentials(b.get("text", ""))} + if b.get("type") == "text" + else b + for b in user_input + ] + + @dataclass class ChatSessionConfig: """All construction parameters for a ChatSession. @@ -1379,6 +1399,7 @@ def _schedule_cerebellum_flush(self) -> None: cb.reset() async def turn(self, user_input: str | list[dict]) -> str: + user_input = _scrub_user_input(user_input) self._append_history({"role": "user", "content": user_input}) user_msg_str = ( @@ -1541,6 +1562,7 @@ async def turn_stream( (e.g. an eval-run id) without any change to Anton. """ self._current_turn_id = turn_id + user_input = _scrub_user_input(user_input) self._append_history({"role": "user", "content": user_input}) # Log user input to episodic memory diff --git a/anton/tools.py b/anton/tools.py index 45fec2f8..1a6ae53e 100644 --- a/anton/tools.py +++ b/anton/tools.py @@ -18,7 +18,11 @@ "password", "secret", "token", "api_key", "key", "auth", "credential", "private", ) -SCRUBBED_VALUE_RE = re.compile(r"^\[DS_\w+\]$") +# All marker forms `scrub_credentials` emits: [DS_*] for vault secrets, +# [] labels for provider keys, [REDACTED_API_KEY] for shape matches. +# User messages are scrubbed too, so the model may see these and echo them +# back as known_variables — they must never be saved as real credentials. +SCRUBBED_VALUE_RE = re.compile(r"^\[(?:DS_\w+|[A-Z][A-Z0-9_]*)\]$") def looks_secret(field_name: str) -> bool: @@ -77,7 +81,7 @@ async def handle_connect_datasource(session: ChatSession, tc_input: dict) -> str console.print() console.print( f"[anton.warning](anton)[/] Ignoring scrubbed-placeholder values " - f"for {', '.join(dropped_scrubbed)} — those [DS_...] strings are " + f"for {', '.join(dropped_scrubbed)} — those bracketed strings are " f"scrub-markers, not real credentials. Pass the actual secret " f"values instead." ) diff --git a/tests/test_scrubbing.py b/tests/test_scrubbing.py index 2fbfd315..a7cc4772 100644 --- a/tests/test_scrubbing.py +++ b/tests/test_scrubbing.py @@ -5,6 +5,7 @@ import pytest +from anton.core.session import _scrub_user_input from anton.utils.datasources import ( _DS_KNOWN_VARS, _DS_SECRET_VARS, @@ -105,3 +106,36 @@ def test_short_sk_and_base_url_left_readable(self, monkeypatch): result = scrub_credentials("sk-abc connecting to https://api.openai.com/v1") assert "sk-abc" in result assert "https://api.openai.com/v1" in result + + +class TestScrubUserInput: + """User messages are scrubbed before entering session history (ENG-583).""" + + def test_string_input_key_redacted(self): + result = _scrub_user_input( + "use this key: sk-ant-api03-abcdefghij1234567890XYZ" + ) + assert "sk-ant-api03" not in result + assert "[REDACTED_API_KEY]" in result + + def test_plain_string_unchanged(self): + text = "please connect me to my staging database" + assert _scrub_user_input(text) == text + + def test_text_blocks_scrubbed_other_blocks_untouched(self): + blocks = [ + {"type": "text", "text": "key is mdb_AAAAAAAAAA.BBBBBBBBBBBBCCCC"}, + {"type": "image", "source": {"type": "base64", "data": "aGk="}}, + ] + result = _scrub_user_input(blocks) + assert "mdb_AAAAAAAAAA" not in result[0]["text"] + assert "[REDACTED_API_KEY]" in result[0]["text"] + assert result[1] is blocks[1] + + def test_known_secret_env_value_redacted_with_label(self, monkeypatch): + """A pasted value matching a stored provider secret gets its var label.""" + key = "sk-proj-abcDEF1234567890abcDEF1234567890" + monkeypatch.setenv("OPENAI_API_KEY", key) + result = _scrub_user_input(f"my key is {key}") + assert key not in result + assert "[OPENAI_API_KEY]" in result diff --git a/tests/test_tools.py b/tests/test_tools.py index 92e3c9bd..54842470 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -307,6 +307,34 @@ async def test_scrubbed_placeholder_values_are_dropped(self, vault_dir): assert "scrubbed-placeholder" in printed assert "api_key" in printed + @pytest.mark.asyncio + async def test_redacted_and_env_label_markers_are_dropped(self, vault_dir): + """User-message scrub markers ([REDACTED_*], []) must not be vaulted.""" + session = _make_session(vault_dir) + interactive = _fake_interactive(session) + with patch( + "anton.commands.datasource.handle_connect_datasource", new=interactive + ): + await handle_connect_datasource( + session, + { + "engine": "posthog", + "known_variables": { + "api_key": "[REDACTED_API_KEY]", + "host": "[OPENAI_API_KEY]", + }, + }, + ) + + interactive.assert_called_once() + assert session._data_vault.list_connections() == [] + printed = " ".join( + str(c.args[0]) + for c in session._console.print.call_args_list + if c.args + ) + assert "scrubbed-placeholder" in printed + @pytest.mark.asyncio async def test_mixed_real_and_scrubbed_values(self, vault_dir, tmp_path): """Real values are saved; scrubbed placeholders are dropped with a warning.""" From b91a6329be0721fa8a4c363dd18cb0152a409abc Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Tue, 7 Jul 2026 19:31:45 -0700 Subject: [PATCH 2/3] =?UTF-8?q?fix(cli):=20restore=20Anton=20CLI=20for=20M?= =?UTF-8?q?indsHub=20users=20=E2=80=94=20minds-cloud=20crash=20+=20self-up?= =?UTF-8?q?date=20loop=20(ENG-655)=20(#240)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): restore Anton CLI for MindsHub users — minds-cloud crash + self-update loop (ENG-655) Two verified failures that made the standalone `anton` CLI unusable for MindsHub users (desktop app + webapp are unaffected — they build the LLM client via cowork-server's own build_llm_client and never call this updater). 1. Crash: `ValueError: Unknown planning provider: minds-cloud`. The CLI's LLMClient registry only has anthropic/openai/openai-compatible; MindsHub speaks the OpenAI-compatible API but the desktop/consolidated config uses the first-class name "minds-cloud", which the CLI never mapped. Add an AntonSettings field-validator that normalizes minds-cloud (and the underscore spelling) → openai-compatible, so model_post_init derives the creds/base from the minds_* fields and the existing provider handles it. Reproduced the crash and the fix in a test. 2. Perpetual failed self-update that corrupts installs (reported on Windows). `__version__` was hardcoded in anton/__init__.py and drifted from the release tags (stuck at 2.26.6.30.1 across several releases) because the release workflow only tags (pyproject is hatch-vcs / version-from-tag). The updater read that stale constant as local_ver, so remote(tag) > local was always true → it force-reinstalled the running tool on every launch → on Windows that partial reinstall bricked the env (missing rich._emoji_codes, typer rich_utils ImportError, or "No module named 'anton'"). - Derive __version__ from installed package metadata (importlib.metadata) so it always matches what's installed — kills the drift class at the root. - Anti-thrash guard in updater.py: record a tag whose reinstall didn't take and don't force-reinstall that same tag again until a newer one appears. 3. Escape hatch + dep cap: add a discoverable `--no-update` CLI flag (surfacing the existing ANTON_DISABLE_AUTOUPDATES setting), and cap `rich<15` (rich 14 + typer 0.24 verified compatible; the reported errors were reinstall corruption, not a version range). Tests: 5 for minds-cloud normalization incl. the from_settings crash repro; 3 for the updater anti-thrash marker. Full suite passes (the 2 scratchpad failures are pre-existing/environmental — they fail on clean staging too; subprocess can't spawn in the sandbox). NOTE for reviewer: the updater changes (dynamic version + anti-thrash) resolve the loop deterministically in unit tests, but the uv-tool-install / hatch-vcs interaction should be sanity-checked with a real `uv tool install git+...@` on macOS + Windows before relying on it in the wild. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(updater): read anton-agent's version specifically, not a leftover legacy anton tool (ENG-655) Local uv-flow testing surfaced an additional root cause of the update loop. `_read_installed_anton_version()` matched `re.search(r"anton\s+(\S+)")`, but a real machine can have BOTH a leftover legacy `anton` tool (pre-rename) and the current `anton-agent` tool — `uv tool list` shows `anton` first, so the bare regex read the legacy tool's version (2.26.5.13.1) instead of anton-agent's (2.26.7.6.2). That made installed_ver != remote_ver → "Update skipped" forever, even after a correct install, and combined with the new anti-thrash marker would permanently block updates. Anchor the match to `anton-agent` (line-start, optional v prefix). Verified against the real `uv tool list` output. Two tests: dual-tool ordering, and the absent-anton-agent case (returns None → caller bails safely). Co-Authored-By: Claude Opus 4.8 (1M context) * review(cli): dual-dist version fallback + case-tolerant minds-cloud validator (ENG-655) Adversarial review of PR #240 surfaced two real gaps: 1. __version__ resolved only the "anton-agent" distribution → on a legacy install under the old "anton" dist name it raised PackageNotFoundError and fell back to "0.0.0.dev0", which makes the updater see local < remote and force-reinstall once per release — reintroducing the exact ENG-655 loop for that cohort. Now try "anton-agent" then "anton" before the dev fallback. Also wrap the whole block defensively: __init__ runs on every `import anton` (incl. the desktop's cowork-server), so it must never raise. 2. The minds-cloud → openai-compatible validator was case-sensitive; "MINDS-CLOUD" or padded values slipped through and re-hit the crash. Now lower()/strip() tolerant. Cross-surface review confirmed no app/webapp/instance regressions (validate_assignment is off so the harness setattr path is unaffected; cowork-server reads importlib.metadata directly, not anton.__version__; rich 14.3.3 satisfies <15; updater is CLI-only). Tests: added case/whitespace normalization case; suite 1148 passed (2 pre-existing scratchpad-subprocess failures unrelated, fail on clean staging too). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- anton/__init__.py | 25 ++++++++- anton/cli.py | 6 ++- anton/config/settings.py | 17 ++++++ anton/updater.py | 40 +++++++++++++- pyproject.toml | 2 +- tests/test_settings.py | 56 ++++++++++++++++++++ tests/test_updater.py | 109 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 4 deletions(-) create mode 100644 tests/test_updater.py diff --git a/anton/__init__.py b/anton/__init__.py index c7d4cf3e..258da22c 100644 --- a/anton/__init__.py +++ b/anton/__init__.py @@ -1 +1,24 @@ -__version__ = "2.26.6.30.1" +# Version is derived from the installed package metadata (hatch-vcs sets it from +# the git tag at build/install time — see pyproject `[tool.hatch.version]`). +# Never hardcode it here: a manually-maintained constant drifted from the release +# tags (stuck at 2.26.6.30.1 across several releases), which made the CLI +# self-updater compare a stale local version against the real release tag and +# "update" on every launch forever (ENG-655). +# +# Try the current distribution name first, then the legacy "anton" name (some +# installs predate the anton -> anton-agent rename); fall back to a dev version +# only when no metadata exists (source checkout). The whole block is defensively +# wrapped: this runs on EVERY `import anton` — including the desktop app's +# cowork-server — so it must never raise, or it would brick all anton imports. +try: + from importlib.metadata import PackageNotFoundError, version as _pkg_version + + __version__ = "0.0.0.dev0" + for _dist in ("anton-agent", "anton"): + try: + __version__ = _pkg_version(_dist) + break + except PackageNotFoundError: + continue +except Exception: # pragma: no cover - metadata machinery should never fail import + __version__ = "0.0.0.dev0" diff --git a/anton/cli.py b/anton/cli.py index 768afc18..6925ea18 100644 --- a/anton/cli.py +++ b/anton/cli.py @@ -361,6 +361,10 @@ def main( resume: bool = typer.Option( False, "--resume", "-r", help="Resume a previous chat session" ), + no_update: bool = typer.Option( + False, "--no-update", help="Skip the auto-update check for this run " + "(same as ANTON_DISABLE_AUTOUPDATES=true)" + ), ) -> None: """Anton — a self-evolving autonomous system.""" from anton.config.settings import AntonSettings @@ -373,7 +377,7 @@ def main( from anton.updater import check_and_update - if check_and_update(console, settings): + if not no_update and check_and_update(console, settings): # Mark the env before replacing the process so the next invocation # skips the update check and doesn't loop. os.environ["_ANTON_UPDATED"] = "1" diff --git a/anton/config/settings.py b/anton/config/settings.py index 4dc49871..31d72ee2 100644 --- a/anton/config/settings.py +++ b/anton/config/settings.py @@ -36,6 +36,23 @@ class AntonSettings(CoreSettings): coding_provider: str = "anthropic" coding_model: str = "claude-haiku-4-5-20251001" + @field_validator("planning_provider", "coding_provider", mode="before") + @classmethod + def _map_minds_cloud_to_openai_compatible(cls, v: object) -> object: + """MindsHub is an OpenAI-compatible endpoint, so the CLI serves it via + the ``openai-compatible`` provider (creds/base derived from the minds_* + fields in ``model_post_init``). The desktop app and the consolidated + ``~/.cowork/.env`` use the first-class provider name ``minds-cloud``, + which the CLI's ``LLMClient`` registry has no entry for — so a shared + config crashed the CLI with ``Unknown planning provider: minds-cloud``. + Normalise it here so both names resolve to the same working provider + (ENG-655). Tolerant of case, surrounding whitespace, and the underscore + spelling. + """ + if isinstance(v, str) and v.strip().lower().replace("_", "-") == "minds-cloud": + return "openai-compatible" + return v + # Opaque reasoning-effort level (e.g. "low" | "medium" | "high" | "xhigh" | # "max"), forwarded to the provider in its native shape when set. None means # the provider's own default. The value is validated by the upstream diff --git a/anton/updater.py b/anton/updater.py index 664f0103..c779abab 100644 --- a/anton/updater.py +++ b/anton/updater.py @@ -7,10 +7,19 @@ import subprocess import threading import urllib.request +from pathlib import Path _TOTAL_TIMEOUT = 10 # Hard ceiling — update check never blocks startup longer than this +# Records the last release tag whose force-reinstall did NOT take (the installed +# version never matched the tag). A fresh terminal would otherwise re-run +# `uv tool install --force` for that same doomed tag on every launch — which can +# corrupt the running tool env (partial rich/typer, or the package vanishing), +# especially on Windows (ENG-655). We suppress re-attempting the SAME tag until a +# newer one appears. +_SKIP_MARKER = Path("~/.anton/.update_skip_tag").expanduser() + _RELEASES_LATEST_URL = "https://api.github.com/repos/mindsdb/anton/releases/latest" _GITHUB_API_HEADERS = {"Accept": "application/vnd.github+json"} @@ -85,6 +94,17 @@ def _check_and_update(result: dict, settings) -> None: if remote_ver <= local_ver: return + # Anti-thrash: if a previous run already force-reinstalled this exact tag and + # it didn't take (installed version never matched — e.g. a tag/metadata + # mismatch), don't reinstall it again. Re-running `uv tool install --force` + # of the running tool every launch is what corrupts the env (ENG-655); wait + # for a genuinely newer tag instead. + try: + if _SKIP_MARKER.is_file() and _SKIP_MARKER.read_text().strip() == latest_tag: + return + except Exception: + pass + # Newer version available — reinstall from the specific release tag messages.append(f" Updating anton {local_ver} \u2192 {remote_ver}...") @@ -110,11 +130,23 @@ def _check_and_update(result: dict, settings) -> None: messages.append(" [dim]Update could not be verified, continuing...[/]") return if installed_ver != remote_ver: + # Remember this tag so we don't force-reinstall it again next launch + # (see _SKIP_MARKER) \u2014 that repeated reinstall is what bricks installs. + try: + _SKIP_MARKER.parent.mkdir(parents=True, exist_ok=True) + _SKIP_MARKER.write_text(latest_tag) + except Exception: + pass messages.append( " [dim]Update skipped: installed Anton version does not match the latest release tag.[/]" ) return + # Success \u2014 clear any stale skip marker so a future re-install isn't blocked. + try: + _SKIP_MARKER.unlink(missing_ok=True) + except Exception: + pass messages.append(" \u2713 Updated!") result["new_version"] = remote_version_str @@ -148,7 +180,13 @@ def _read_installed_anton_version(): return None tool_list = verify.stdout.decode() - installed_match = re.search(r"anton\s+(\S+)", tool_list) + # Match the `anton-agent` tool specifically (line-anchored). A bare + # `anton\s+` also matches a leftover legacy `anton` tool (from before the + # package was renamed anton -> anton-agent) — which is listed first, so the + # verification read the WRONG tool's version and "Update skipped" fired + # forever even after a correct install (ENG-655, confirmed on a real + # machine that had both `anton` and `anton-agent` uv tools). + installed_match = re.search(r"^anton-agent\s+v?(\S+)", tool_list, re.MULTILINE) if not installed_match: return None diff --git a/pyproject.toml b/pyproject.toml index b7276312..49dcb0cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ dependencies = [ "pydantic>=2.0", "pydantic-settings>=2.0", "typer>=0.12.4", - "rich>=13.0", + "rich>=13.0,<15", "prompt-toolkit>=3.0", "packaging>=21.0", "pyyaml>=6.0", diff --git a/tests/test_settings.py b/tests/test_settings.py index 4ad71c97..a45232a4 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -159,3 +159,59 @@ def test_no_derivation_when_openai_key_present(self, monkeypatch): # Derivation is skipped because openai_api_key is already set. assert s.openai_api_key == "sk-real-user-key" assert s.openai_base_url is None + + +class TestMindsCloudProviderNormalization: + """A shared/desktop config sets provider = 'minds-cloud', but the CLI's + LLMClient registry only has 'openai-compatible' (MindsHub speaks the + OpenAI-compatible API). Without normalization the CLI crashed with + 'Unknown planning provider: minds-cloud' (ENG-655).""" + + def test_minds_cloud_planning_maps_to_openai_compatible(self, monkeypatch): + for k in _ANTON_MODEL_KEYS: + monkeypatch.delenv(k, raising=False) + s = AntonSettings(planning_provider="minds-cloud", _env_file=None) + assert s.planning_provider == "openai-compatible" + + def test_minds_cloud_coding_maps_to_openai_compatible(self, monkeypatch): + for k in _ANTON_MODEL_KEYS: + monkeypatch.delenv(k, raising=False) + s = AntonSettings(coding_provider="minds-cloud", _env_file=None) + assert s.coding_provider == "openai-compatible" + + def test_underscore_spelling_also_maps(self, monkeypatch): + for k in _ANTON_MODEL_KEYS: + monkeypatch.delenv(k, raising=False) + s = AntonSettings(planning_provider="minds_cloud", _env_file=None) + assert s.planning_provider == "openai-compatible" + + def test_case_and_whitespace_tolerant(self, monkeypatch): + for k in _ANTON_MODEL_KEYS: + monkeypatch.delenv(k, raising=False) + for variant in ("MINDS-CLOUD", " Minds_Cloud ", "minds_cloud"): + s = AntonSettings(planning_provider=variant, _env_file=None) + assert s.planning_provider == "openai-compatible", variant + + def test_other_providers_pass_through(self, monkeypatch): + for k in _ANTON_MODEL_KEYS: + monkeypatch.delenv(k, raising=False) + for p in ("anthropic", "openai", "openai-compatible"): + s = AntonSettings(planning_provider=p, _env_file=None) + assert s.planning_provider == p + + def test_from_settings_does_not_crash_on_minds_cloud(self, monkeypatch): + # The exact regression: building the LLM client from a minds-cloud + # config must not raise "Unknown planning provider". + for k in _ANTON_MODEL_KEYS: + monkeypatch.delenv(k, raising=False) + from anton.core.llm.client import LLMClient + s = AntonSettings( + planning_provider="minds-cloud", + coding_provider="minds-cloud", + minds_api_key="mdb_dummy", + minds_url="https://api.mindshub.ai/v1", + _env_file=None, + ) + # Should build without raising; creds derived from the minds_* fields. + LLMClient.from_settings(s) + assert s.openai_base_url == "https://api.mindshub.ai/v1" diff --git a/tests/test_updater.py b/tests/test_updater.py new file mode 100644 index 00000000..4df8fe3a --- /dev/null +++ b/tests/test_updater.py @@ -0,0 +1,109 @@ +"""Anti-thrash guard for the CLI self-updater (ENG-655). + +When a release tag's installed version never matches the tag (version drift), +the updater must NOT re-run `uv tool install --force` for that same tag on every +launch — that repeated reinstall of the running tool corrupts the env (Windows). +""" +from __future__ import annotations + +from packaging.version import Version + +import anton.updater as updater + + +class _FailProc: + # returncode != 0 → updater stops right after the install attempt, so tests + # that only need to observe "did we attempt?" don't have to stub `uv tool list`. + returncode = 1 + stdout = b"" + + +def _stub_env(monkeypatch, tmp_path, *, latest_tag, local_version, proc=None): + """Stub network/uv so only the guard logic runs; record install calls.""" + calls = {"install": 0} + result_proc = proc or _FailProc() + + monkeypatch.setattr(updater.shutil, "which", lambda _: "/usr/bin/uv") + monkeypatch.setattr(updater, "_fetch_latest_release_tag", lambda: latest_tag) + monkeypatch.setattr(updater, "_SKIP_MARKER", tmp_path / ".update_skip_tag") + + import anton + monkeypatch.setattr(anton, "__version__", local_version) + + def _rec_run(*a, **k): + calls["install"] += 1 + return result_proc + + monkeypatch.setattr(updater.subprocess, "run", _rec_run) + return calls + + +def test_marked_tag_is_not_reinstalled(monkeypatch, tmp_path): + # A newer tag exists (would normally trigger an update)... + calls = _stub_env(monkeypatch, tmp_path, latest_tag="v9.9.9.9.9", local_version="2.0.0") + # ...but we already recorded it as a failed attempt. + (tmp_path / ".update_skip_tag").write_text("v9.9.9.9.9") + + result: dict = {} + updater._check_and_update(result, settings=None) + + assert calls["install"] == 0 # guard short-circuited before the reinstall + assert not any("Updating" in m for m in result.get("messages", [])) + + +def test_different_tag_is_not_suppressed(monkeypatch, tmp_path): + # Marker holds an OLD tag; a genuinely newer tag must still be attempted. + calls = _stub_env(monkeypatch, tmp_path, latest_tag="v9.9.9.9.9", local_version="2.0.0") + (tmp_path / ".update_skip_tag").write_text("v1.1.1.1.1") + + result: dict = {} + updater._check_and_update(result, settings=None) + + assert calls["install"] == 1 # newer tag → install attempted + + +def test_skip_marker_written_on_version_mismatch(monkeypatch, tmp_path): + # Install "succeeds" but the verified version still diverges from the tag → + # record the tag so the next launch doesn't reinstall it again. + class _OkProc: + returncode = 0 + stdout = b"" + + _stub_env(monkeypatch, tmp_path, latest_tag="v9.9.9.9.9", local_version="2.0.0", proc=_OkProc()) + # Verified installed version != remote tag (the drift case). + monkeypatch.setattr(updater, "_read_installed_anton_version", lambda: Version("2.0.0")) + + result: dict = {} + updater._check_and_update(result, settings=None) + + marker = tmp_path / ".update_skip_tag" + assert marker.is_file() and marker.read_text().strip() == "v9.9.9.9.9" + assert "new_version" not in result + + +def test_read_installed_version_picks_anton_agent_not_legacy_anton(monkeypatch): + """Real dual-tool state (confirmed on a live machine): a leftover legacy + `anton` tool is listed BEFORE `anton-agent`. `_read_installed_anton_version` + must read anton-agent's version, not the legacy tool's (ENG-655).""" + class _Proc: + returncode = 0 + stdout = ( + b"anton v2.26.5.13.1\n- anton\n" + b"anton-agent v2.26.7.6.2\n- anton\n" + b"cowork-server v0.26.7.6.4\n- cowork-server\n" + ) + + monkeypatch.setattr(updater.subprocess, "run", lambda *a, **k: _Proc()) + ver = updater._read_installed_anton_version() + assert str(ver) == "2.26.7.6.2" + + +def test_read_installed_version_none_when_anton_agent_absent(monkeypatch): + # Only the legacy tool present → no anton-agent to verify → None (safe; + # caller treats it as "could not verify" and bails without corruption). + class _Proc: + returncode = 0 + stdout = b"anton v2.26.5.13.1\n- anton\n" + + monkeypatch.setattr(updater.subprocess, "run", lambda *a, **k: _Proc()) + assert updater._read_installed_anton_version() is None From 8ba42324ee227cc5fd98c13333cc55beddabed54 Mon Sep 17 00:00:00 2001 From: Alejandro Cantu Date: Wed, 8 Jul 2026 18:06:02 -0700 Subject: [PATCH 3/3] feat(llm): shared status-error mapper + typed model-403s (ENG-598) (#236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(llm): shared status-error mapper + typed model-403s (ENG-598) A gateway 403 for a tier-locked/disabled model surfaced in chat as "An unexpected error occurred: Server returned 403 — the LLM endpoint may be temporarily unavailable. Try again in a moment.. Please try again or rephrase your request." — wrong cause, useless advice, double-wrapped. Two layers: 1) openai.py mapped every non-401/429 APIStatusError to the generic "temporarily unavailable" copy, in four copy-pasted blocks (complete / stream / _complete_via_responses / _stream_via_responses) that had already drifted in wording. 2) session.py's plan_stream fallback swallowed the exception and yielded it as assistant TEXT, so the turn "succeeded" and cowork-server's turn-error mapping (the error-card machinery) never ran. Fix: - One shared _raise_for_status_error(exc, model) used by all four call paths so the mapping can't drift. 401 and 429 behavior unchanged (same copy the downstream provider_auth / token_limit detections key on; the "or to top up" wording drift normalized). - New ModelUnavailableError(ConnectionError) in provider.py carrying {code, model} for the gateway's structured 403s: model_access_denied → plan copy with upgrade/switch guidance; model_disabled → hedged copy (can be tier lock or admin kill switch until ENG-596's config lands). Detection is code-exact on error.code — BYOK OpenAI 403s, Anthropic permission errors, and Cloudflare HTML 403s carry no such code and fall through to the generic message, never the plan copy. Subclassing ConnectionError keeps every legacy call site working unchanged. - session.py: TokenLimitExceeded / ModelUnavailableError now PROPAGATE from the plan_stream fallback instead of being wrapped into prose, so the turn fails and the server can render an actionable card. All other exceptions keep the existing text fallback. Tests: 11 new (401 copy pin, 429 quota + precedence over model-403, both gateway codes incl. attrs/copy, ConnectionError compatibility, BYOK/HTML/ codeless 403 fall-through, 500 generic). Suite: 1147 passed; the 2 test_chat_scratchpad failures pre-exist on clean staging (env-dependent). Co-Authored-By: Claude Opus 4.8 (1M context) * review: don't auto-retry ModelUnavailableError — deterministic like TokenLimitExceeded A model-gate 403 is deterministic: the recovery loop would re-send the same doomed request twice (burning the retry budget + user-visible delay) before the fallback path finally propagated it. Short-circuit at the outer catch, exactly like TokenLimitExceeded. Co-Authored-By: Claude Opus 4.8 (1M context) * review(598): steer CLI default to setup for ModelUnavailableError; mapper doc/type nits Addresses lucas-koontz's review on PR #236 (all non-blocking; PR approved): - CLI turn handler (chat.py) defaulted to "retry" for any ConnectionError, and ModelUnavailableError subclasses ConnectionError — so a deterministic plan gate highlighted "retry" as the default, re-sending the same doomed request (contradicting the session.py comment). Now defaults to "setup" (switch model/provider) for ModelUnavailableError; transient ConnectionErrors keep retry. - Fixed the grammatically-broken 429 docstring bullet in _raise_for_status_error. - _raise_for_status_error typed -> NoReturn (it always raises); documents the contract the four except-sites rely on. Mapper + session tests: 11 passed. (Pre-existing F541s elsewhere in chat.py left untouched — not in scope.) Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- anton/chat.py | 11 ++- anton/core/llm/openai.py | 128 +++++++++++++------------- anton/core/llm/provider.py | 21 +++++ anton/core/session.py | 18 +++- tests/test_status_error_mapper.py | 143 ++++++++++++++++++++++++++++++ 5 files changed, 254 insertions(+), 67 deletions(-) create mode 100644 tests/test_status_error_mapper.py diff --git a/anton/chat.py b/anton/chat.py index 9cd2ef67..d9f34ecd 100644 --- a/anton/chat.py +++ b/anton/chat.py @@ -23,6 +23,7 @@ from anton.core.session import ChatSession, ChatSessionConfig from anton.core.llm.prompt_builder import SystemPromptContext from anton.core.llm.provider import ( + ModelUnavailableError, TokenLimitExceeded, StreamComplete, StreamContextCompacted, @@ -1886,7 +1887,15 @@ def _bottom_toolbar(): " (anton) Switch LLM provider, update API key, or retry?", choices=["setup", "retry", "s", "r"], choices_display="setup/retry", - default="retry" if isinstance(exc, ConnectionError) else "setup", + # A ModelUnavailableError is a deterministic plan/kill-switch + # gate — retrying re-sends the same doomed request — so steer + # the default to "setup" (switch model / provider). Other + # ConnectionErrors (transient) keep retry as the default. + default=( + "setup" + if isinstance(exc, ModelUnavailableError) + else ("retry" if isinstance(exc, ConnectionError) else "setup") + ), ) if choice in ("setup", "s"): session = await handle_setup_models( diff --git a/anton/core/llm/openai.py b/anton/core/llm/openai.py index b75bc4bb..8a863609 100644 --- a/anton/core/llm/openai.py +++ b/anton/core/llm/openai.py @@ -3,6 +3,7 @@ import json import os from collections.abc import AsyncIterator +from typing import NoReturn import openai from openai import AsyncAzureOpenAI @@ -12,6 +13,7 @@ ContextOverflowError, LLMProvider, LLMResponse, + ModelUnavailableError, ProviderConnectionInfo, StreamComplete, StreamEvent, @@ -19,12 +21,70 @@ StreamToolUseDelta, StreamToolUseEnd, StreamToolUseStart, + TokenLimitExceeded, ToolCall, Usage, compute_context_pressure, ) +def _raise_for_status_error(exc: "openai.APIStatusError", model: str) -> NoReturn: + """Map a provider HTTP error onto anton's typed/curated exceptions. + + The single mapper shared by all four call paths (chat/stream × + completions/responses) so the mapping can't drift between them — the + previous four copy-pasted blocks had already diverged in wording. + + Mapping policy: + - 401 → ConnectionError with the invalid-key copy (cowork-server's + provider_auth detection keys on this exact phrase). + - 403 with a structured gateway code (``error.code`` of + ``model_access_denied`` / ``model_disabled``) → ModelUnavailableError + carrying the code + model, with actionable copy. Detection is + code-exact on purpose: BYOK OpenAI 403s (region blocks), Anthropic + permission errors, and Cloudflare HTML 403s carry no such code and + must fall through to the generic message, never the plan copy. + - 429 with a quota detail → TokenLimitExceeded, checked first so a body + carrying both ``detail`` and ``error.code`` stays token_limit (quota + keeps its own card downstream). + - anything else → the generic "temporarily unavailable" ConnectionError. + """ + if exc.status_code == 401: + raise ConnectionError( + "Invalid API key — check your OpenAI API key configuration." + ) from exc + + body = exc.body if isinstance(exc.body, dict) else {} + if exc.status_code == 429 and body.get("detail"): + msg = f"Server returned 429 — {body['detail']}" + msg += " Visit https://console.mindshub.ai to upgrade or top up your tokens." + raise TokenLimitExceeded(msg) from exc + + error = body.get("error") if isinstance(body.get("error"), dict) else {} + code = error.get("code") + if exc.status_code == 403 and code in ("model_access_denied", "model_disabled"): + if code == "model_access_denied": + msg = ( + f"The model '{model}' isn't included in your current MindsHub plan. " + "Visit https://console.mindshub.ai to upgrade, or switch models in Settings." + ) + else: + # Hedged: until the gateway distinguishes tier locks from admin + # kill switches everywhere (ENG-596), model_disabled can mean + # either — don't promise that an upgrade fixes it. + msg = ( + f"The model '{model}' isn't available right now — it may not be included " + "in your plan, or it's temporarily disabled. Switch models in Settings; " + "upgrading your plan may enable it." + ) + raise ModelUnavailableError(msg, code=code, model=model) from exc + + raise ConnectionError( + f"Server returned {exc.status_code} — the LLM endpoint may be " + "temporarily unavailable. Try again in a moment." + ) from exc + + def _translate_tools(tools: list[dict]) -> list[dict]: """Anthropic tool format -> OpenAI function-calling format.""" result = [] @@ -709,22 +769,7 @@ async def complete( raise ContextOverflowError(str(exc)) from exc raise except openai.APIStatusError as exc: - if exc.status_code == 401: - msg = "Invalid API key — check your OpenAI API key configuration." - raise ConnectionError(msg) from exc - elif ( - exc.status_code == 429 - and isinstance(exc.body, dict) - and exc.body.get("detail") - ): - msg = f"Server returned 429 — {exc.body['detail']}" - msg += " Visit https://console.mindshub.ai to upgrade or to top up your tokens." - from .provider import TokenLimitExceeded - - raise TokenLimitExceeded(msg) from exc - else: - msg = f"Server returned {exc.status_code} — the LLM endpoint may be temporarily unavailable. Try again in a moment." - raise ConnectionError(msg) from exc + _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: raise ConnectionError( "Could not reach the LLM server — check your connection or try again in a moment." @@ -878,22 +923,7 @@ async def stream( raise ContextOverflowError(str(exc)) from exc raise except openai.APIStatusError as exc: - if exc.status_code == 401: - msg = "Invalid API key — check your OpenAI API key configuration." - raise ConnectionError(msg) from exc - elif ( - exc.status_code == 429 - and isinstance(exc.body, dict) - and exc.body.get("detail") - ): - msg = f"Server returned 429 — {exc.body['detail']}" - msg += " Visit https://console.mindshub.ai to upgrade or top up your tokens." - from .provider import TokenLimitExceeded - - raise TokenLimitExceeded(msg) from exc - else: - msg = f"Server returned {exc.status_code} — the LLM endpoint may be temporarily unavailable. Try again in a moment." - raise ConnectionError(msg) from exc + _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: raise ConnectionError( "Could not reach the LLM server — check your connection or try again in a moment." @@ -996,22 +1026,7 @@ async def _complete_via_responses( raise ContextOverflowError(str(exc)) from exc raise except openai.APIStatusError as exc: - if exc.status_code == 401: - msg = "Invalid API key — check your OpenAI API key configuration." - raise ConnectionError(msg) from exc - elif ( - exc.status_code == 429 - and isinstance(exc.body, dict) - and exc.body.get("detail") - ): - msg = f"Server returned 429 — {exc.body['detail']}" - msg += " Visit https://console.mindshub.ai to upgrade or to top up your tokens." - from .provider import TokenLimitExceeded - - raise TokenLimitExceeded(msg) from exc - else: - msg = f"Server returned {exc.status_code} — the LLM endpoint may be temporarily unavailable. Try again in a moment." - raise ConnectionError(msg) from exc + _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: raise ConnectionError( "Could not reach the LLM server — check your connection or try again in a moment." @@ -1125,22 +1140,7 @@ async def _stream_via_responses( raise ContextOverflowError(str(exc)) from exc raise except openai.APIStatusError as exc: - if exc.status_code == 401: - msg = "Invalid API key — check your OpenAI API key configuration." - raise ConnectionError(msg) from exc - elif ( - exc.status_code == 429 - and isinstance(exc.body, dict) - and exc.body.get("detail") - ): - msg = f"Server returned 429 — {exc.body['detail']}" - msg += " Visit https://console.mindshub.ai to upgrade or top up your tokens." - from .provider import TokenLimitExceeded - - raise TokenLimitExceeded(msg) from exc - else: - msg = f"Server returned {exc.status_code} — the LLM endpoint may be temporarily unavailable. Try again in a moment." - raise ConnectionError(msg) from exc + _raise_for_status_error(exc, model) except openai.APIConnectionError as exc: raise ConnectionError( "Could not reach the LLM server — check your connection or try again in a moment." diff --git a/anton/core/llm/provider.py b/anton/core/llm/provider.py index 10727a20..19732fd3 100644 --- a/anton/core/llm/provider.py +++ b/anton/core/llm/provider.py @@ -312,6 +312,27 @@ class TokenLimitExceeded(Exception): """Raised when the LLM returns 429 due to billing/token limits.""" +class ModelUnavailableError(ConnectionError): + """Raised when the gateway rejects the requested model with a structured 403. + + The MindsHub gateway distinguishes two cases via ``error.code``: + + - ``model_access_denied`` — the caller's plan tier doesn't include the + model (an upgrade fixes it). + - ``model_disabled`` — an admin kill switch (an upgrade does NOT fix it). + + Subclasses ConnectionError so call sites that only know the legacy + ConnectionError mapping keep working unchanged; typed consumers + (cowork-server's turn-error mapping) read ``code``/``model`` to pick the + right user-facing remedy instead of string-matching. + """ + + def __init__(self, message: str, *, code: str, model: str) -> None: + super().__init__(message) + self.code = code + self.model = model + + @dataclass class ProviderConnectionInfo: """Serializable provider connection details. diff --git a/anton/core/session.py b/anton/core/session.py index 043c654c..3f260aff 100644 --- a/anton/core/session.py +++ b/anton/core/session.py @@ -25,6 +25,7 @@ ) from anton.core.llm.provider import ( ContextOverflowError, + ModelUnavailableError, StreamComplete, StreamContextCompacted, StreamEvent, @@ -1612,8 +1613,12 @@ async def turn_stream( yield event break # completed successfully except Exception as _agent_exc: - # Token/billing limit — don't retry, let the chat loop handle it - if isinstance(_agent_exc, TokenLimitExceeded): + # Token/billing limits and model-gate 403s are + # deterministic — the auto-retry below would just re-send + # the same doomed request (and burn its budget) before + # failing anyway. Don't retry; let the chat loop / server + # map them to their cards. + if isinstance(_agent_exc, (TokenLimitExceeded, ModelUnavailableError)): raise _retry_count += 1 # Anthropic's API rejects any history where the @@ -1668,6 +1673,15 @@ async def turn_stream( if isinstance(event, StreamTextDelta): assistant_text_parts.append(event.text) yield event + except (TokenLimitExceeded, ModelUnavailableError): + # Curated provider failures must FAIL the turn, not + # get wrapped into assistant prose: the server maps + # them to actionable error cards (token_limit / + # model-unavailable), which can only fire when the + # exception propagates. Wrapping them as text is + # how "Server returned 403" ended up mid-chat with + # "please rephrase your request" advice. + raise except Exception as e: fallback = f"An unexpected error occurred: {e}. Please try again or rephrase your request." assistant_text_parts.append(fallback) diff --git a/tests/test_status_error_mapper.py b/tests/test_status_error_mapper.py new file mode 100644 index 00000000..c1089783 --- /dev/null +++ b/tests/test_status_error_mapper.py @@ -0,0 +1,143 @@ +"""The shared provider status-error mapper (ENG-598). + +One mapper (`openai._raise_for_status_error`) now serves all four call paths +(chat/stream × completions/responses) — previously four copy-pasted blocks +that had already drifted in wording. These tests pin the mapping policy: + +- 401 → ConnectionError with the exact invalid-key copy (cowork-server's + provider_auth detection string-matches it). +- 429 + quota detail → TokenLimitExceeded (and it outranks any 403 logic). +- 403 + structured gateway code → ModelUnavailableError carrying code+model, + with actionable copy per code. +- Any other 403 (BYOK region blocks, Anthropic permission errors, Cloudflare + HTML) → the generic message, NEVER the plan copy. +""" + +import pytest + +from anton.core.llm.openai import _raise_for_status_error +from anton.core.llm.provider import ModelUnavailableError, TokenLimitExceeded + + +class _FakeStatusError(Exception): + """Duck-typed stand-in for openai.APIStatusError (status_code + body).""" + + def __init__(self, status_code, body=None): + super().__init__(f"HTTP {status_code}") + self.status_code = status_code + self.body = body + + +def _gateway_403(code, model="sonnet"): + return _FakeStatusError(403, body={"error": { + "message": f"The model '{model}' is rejected.", + "type": "invalid_request_error", + "param": "model", + "code": code, + }}) + + +# ── 401 ─────────────────────────────────────────────────────────────── + +def test_401_maps_to_invalid_key_connection_error(): + with pytest.raises(ConnectionError) as err: + _raise_for_status_error(_FakeStatusError(401), "sonnet") + # cowork-server's provider_auth detection keys on this exact phrase. + assert "Invalid API key" in str(err.value) + assert not isinstance(err.value, ModelUnavailableError) + + +# ── 429 (quota) ─────────────────────────────────────────────────────── + +def test_429_with_detail_maps_to_token_limit(): + exc = _FakeStatusError(429, body={"detail": "Monthly limit exceeded for tokens: 5/5"}) + with pytest.raises(TokenLimitExceeded) as err: + _raise_for_status_error(exc, "sonnet") + assert "Monthly limit exceeded" in str(err.value) + assert "console.mindshub.ai" in str(err.value) + + +def test_bare_429_stays_generic(): + with pytest.raises(ConnectionError) as err: + _raise_for_status_error(_FakeStatusError(429), "sonnet") + assert "temporarily unavailable" in str(err.value) + + +# ── 403 with structured gateway codes ──────────────────────────────── + +def test_model_access_denied_maps_to_plan_copy(): + with pytest.raises(ModelUnavailableError) as err: + _raise_for_status_error(_gateway_403("model_access_denied"), "sonnet") + e = err.value + assert e.code == "model_access_denied" + assert e.model == "sonnet" + assert "isn't included in your current MindsHub plan" in str(e) + assert "upgrade" in str(e).lower() + + +def test_model_disabled_maps_to_hedged_copy(): + # Hedged until ENG-596's config lands everywhere: model_disabled can be + # either a tier lock or an admin kill switch, so don't promise an upgrade + # fixes it — but don't claim a transient outage either. + with pytest.raises(ModelUnavailableError) as err: + _raise_for_status_error(_gateway_403("model_disabled", model="opus"), "opus") + e = err.value + assert e.code == "model_disabled" + assert e.model == "opus" + assert "Switch models in Settings" in str(e) + assert "temporarily unavailable. Try again" not in str(e) + + +def test_model_unavailable_is_a_connection_error(): + # Legacy call sites only know ConnectionError — the typed error must keep + # flowing through them unchanged. + with pytest.raises(ConnectionError): + _raise_for_status_error(_gateway_403("model_disabled"), "sonnet") + + +# ── 403 WITHOUT the gateway codes: never the plan copy ──────────────── + +def test_byok_openai_403_falls_through_to_generic(): + # e.g. OpenAI region block — different error code. + exc = _FakeStatusError(403, body={"error": { + "code": "unsupported_country_region_territory", + "message": "Country not supported.", + }}) + with pytest.raises(ConnectionError) as err: + _raise_for_status_error(exc, "gpt-4o") + assert not isinstance(err.value, ModelUnavailableError) + assert "temporarily unavailable" in str(err.value) + + +def test_html_403_falls_through_to_generic(): + # Cloudflare/WAF blocks have no JSON body at all. + with pytest.raises(ConnectionError) as err: + _raise_for_status_error(_FakeStatusError(403, body="blocked"), "sonnet") + assert not isinstance(err.value, ModelUnavailableError) + + +def test_403_with_no_code_falls_through_to_generic(): + exc = _FakeStatusError(403, body={"error": {"message": "forbidden"}}) + with pytest.raises(ConnectionError) as err: + _raise_for_status_error(exc, "sonnet") + assert not isinstance(err.value, ModelUnavailableError) + + +# ── precedence: quota beats model-403 logic ─────────────────────────── + +def test_429_with_detail_wins_even_if_error_code_present(): + # A quota failure must stay token_limit, never be misread as model-403. + exc = _FakeStatusError(429, body={ + "detail": "Monthly limit exceeded for tokens: 5/5", + "error": {"code": "model_disabled"}, + }) + with pytest.raises(TokenLimitExceeded): + _raise_for_status_error(exc, "sonnet") + + +# ── other statuses stay generic ─────────────────────────────────────── + +def test_500_stays_generic(): + with pytest.raises(ConnectionError) as err: + _raise_for_status_error(_FakeStatusError(500), "sonnet") + assert "Server returned 500" in str(err.value)