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/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/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/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 f2b718ad..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, @@ -99,6 +100,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 +1400,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 +1563,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 @@ -1590,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 @@ -1646,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/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/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_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_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_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) 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.""" 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