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
25 changes: 24 additions & 1 deletion anton/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
11 changes: 10 additions & 1 deletion anton/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion anton/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
17 changes: 17 additions & 0 deletions anton/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
128 changes: 64 additions & 64 deletions anton/core/llm/openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import json
import os
from collections.abc import AsyncIterator
from typing import NoReturn

import openai
from openai import AsyncAzureOpenAI
Expand All @@ -12,19 +13,78 @@
ContextOverflowError,
LLMProvider,
LLMResponse,
ModelUnavailableError,
ProviderConnectionInfo,
StreamComplete,
StreamEvent,
StreamTextDelta,
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 = []
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down Expand Up @@ -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."
Expand Down
21 changes: 21 additions & 0 deletions anton/core/llm/provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
40 changes: 38 additions & 2 deletions anton/core/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
)
from anton.core.llm.provider import (
ContextOverflowError,
ModelUnavailableError,
StreamComplete,
StreamContextCompacted,
StreamEvent,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = (
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading