Skip to content

Commit 9d17117

Browse files
committed
Merge branch 'main' into scratchpad_service
2 parents 62d779a + 14f3577 commit 9d17117

10 files changed

Lines changed: 296 additions & 23 deletions

File tree

anton/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "2.0.1"
1+
__version__ = "2.0.2"

anton/cli.py

Lines changed: 48 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from anton import __version__
2222

2323
from anton.utils.prompt import prompt_or_cancel
24-
from anton.core.llm.openai import build_chat_completion_kwargs
24+
from anton.core.llm.openai import build_chat_completion_kwargs, _is_azure_endpoint
2525

2626
from anton.chat import ChatSession
2727
from anton.core.session import ChatSessionConfig
@@ -236,6 +236,10 @@ def _make_console() -> Console:
236236

237237

238238
console = _make_console()
239+
_ensure_dependencies(console)
240+
241+
import openai
242+
from openai import AzureOpenAI
239243

240244

241245
def _get_settings(ctx: typer.Context):
@@ -281,8 +285,6 @@ def main(
281285
),
282286
) -> None:
283287
"""Anton — a self-evolving autonomous system."""
284-
_ensure_dependencies(console)
285-
286288
from anton.config.settings import AntonSettings
287289

288290
settings = AntonSettings()
@@ -294,7 +296,9 @@ def main(
294296
from anton.updater import check_and_update
295297

296298
if check_and_update(console, settings):
297-
# Re-exec with the freshly installed code so no old modules remain in memory.
299+
# Mark the env before replacing the process so the next invocation
300+
# skips the update check and doesn't loop.
301+
os.environ["_ANTON_UPDATED"] = "1"
298302
_reexec()
299303

300304
ctx.ensure_object(dict)
@@ -865,8 +869,6 @@ def _test():
865869

866870
def _setup_openai(settings, ws) -> None:
867871
"""Set up OpenAI with a single model for both reasoning and coding."""
868-
import openai
869-
870872
console.print()
871873
while True:
872874
api_key = _setup_prompt("API key", is_password=True)
@@ -919,8 +921,6 @@ def _test():
919921

920922
def _setup_gemini(settings, ws) -> None:
921923
"""Set up Google Gemini via its OpenAI-compatible endpoint."""
922-
import openai
923-
924924
_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
925925

926926
console.print()
@@ -977,10 +977,22 @@ def _test():
977977
ws.set_secret("ANTON_CODING_MODEL", model)
978978

979979

980-
def _setup_custom_openai(settings, ws) -> None:
981-
"""Set up a custom OpenAI-compatible endpoint (Ollama, vLLM, Together, Groq, LM Studio, etc.)."""
982-
import openai
983980

981+
def _strip_to_azure_endpoint(raw_url: str) -> str:
982+
"""Return only the scheme+host of a URL, stripping any path/query.
983+
984+
AzureOpenAI constructs the deployment path internally, so the endpoint
985+
must not include /openai/deployments/... or ?api-version=...
986+
"""
987+
from urllib.parse import urlparse
988+
parsed = urlparse(raw_url if "://" in raw_url else f"https://{raw_url}")
989+
scheme = parsed.scheme or "https"
990+
host = parsed.netloc or parsed.path
991+
return f"{scheme}://{host}"
992+
993+
994+
def _setup_custom_openai(settings, ws) -> None:
995+
"""Set up a custom OpenAI-compatible endpoint (Ollama, vLLM, Together, Groq, LM Studio, Azure, etc.)."""
984996
console.print()
985997
console.print(
986998
" [anton.muted]Works with Ollama, vLLM, Together, Groq, LM Studio, or any OpenAI-compatible API.[/]"
@@ -994,7 +1006,6 @@ def _setup_custom_openai(settings, ws) -> None:
9941006
console.print(" [anton.warning]Base URL is required.[/]")
9951007
if not base_url.startswith("http://") and not base_url.startswith("https://"):
9961008
base_url = "http://" + base_url
997-
base_url = base_url.rstrip("/")
9981009

9991010
api_key = _setup_prompt(
10001011
"API key (Enter to skip if not needed)", is_password=True
@@ -1008,10 +1019,32 @@ def _setup_custom_openai(settings, ws) -> None:
10081019
break
10091020
console.print(" [anton.warning]Model name is required.[/]")
10101021

1022+
api_version = _setup_prompt(
1023+
"API version (leave blank for standard endpoints, required for Azure)"
1024+
).strip() or None
1025+
if api_version and _is_azure_endpoint(base_url):
1026+
# Strip path/query — AzureOpenAI builds the deployment URL internally.
1027+
base_url = _strip_to_azure_endpoint(base_url)
1028+
1029+
base_url = base_url.rstrip("/")
1030+
10111031
try:
10121032

10131033
def _test():
1014-
client = openai.OpenAI(api_key=api_key, base_url=base_url)
1034+
if api_version and _is_azure_endpoint(base_url):
1035+
client = AzureOpenAI(
1036+
azure_endpoint=base_url,
1037+
api_key=api_key,
1038+
api_version=api_version,
1039+
)
1040+
elif api_version:
1041+
client = openai.OpenAI(
1042+
api_key=api_key,
1043+
base_url=base_url,
1044+
default_query={"api-version": api_version},
1045+
)
1046+
else:
1047+
client = openai.OpenAI(api_key=api_key, base_url=base_url)
10151048
response = client.chat.completions.create(
10161049
**build_chat_completion_kwargs(
10171050
model=model,
@@ -1032,12 +1065,14 @@ def _test():
10321065

10331066
settings.openai_api_key = api_key
10341067
settings.openai_base_url = base_url
1068+
settings.openai_api_version = api_version
10351069
settings.planning_provider = "openai-compatible"
10361070
settings.coding_provider = "openai-compatible"
10371071
settings.planning_model = model
10381072
settings.coding_model = model
10391073
ws.set_secret("ANTON_OPENAI_API_KEY", api_key)
10401074
ws.set_secret("ANTON_OPENAI_BASE_URL", base_url)
1075+
ws.set_secret("ANTON_OPENAI_API_VERSION", api_version or "")
10411076
ws.set_secret("ANTON_PLANNING_PROVIDER", "openai-compatible")
10421077
ws.set_secret("ANTON_CODING_PROVIDER", "openai-compatible")
10431078
ws.set_secret("ANTON_PLANNING_MODEL", model)

anton/config/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ class AntonSettings(CoreSettings):
3636
anthropic_api_key: str | None = None
3737
openai_api_key: str | None = None
3838
openai_base_url: str | None = None
39+
openai_api_version: str | None = None # Azure api-version query param
3940

4041
memory_enabled: bool = True
4142
memory_dir: str = ".anton"

anton/core/backends/scratchpad_boot.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,10 +79,12 @@ def _dump_namespace(ns: dict) -> str | None:
7979
_llm_api_key = os.environ.get("OPENAI_API_KEY") or os.environ.get(
8080
"ANTON_OPENAI_API_KEY"
8181
)
82+
_llm_api_version = os.environ.get("ANTON_OPENAI_API_VERSION") or None
8283
_llm_provider = _ProviderClass(
8384
api_key=_llm_api_key or None,
8485
base_url=_llm_base_url or None,
8586
ssl_verify=_llm_ssl_verify,
87+
api_version=_llm_api_version,
8688
)
8789
else:
8890
_llm_provider = _ProviderClass() # Anthropic doesn't need ssl_verify

anton/core/llm/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,17 +218,20 @@ def from_settings(cls, settings: AntonSettings) -> LLMClient:
218218
from .anthropic import AnthropicProvider
219219
from .openai import OpenAIProvider
220220

221+
api_version = getattr(settings, "openai_api_version", None)
221222
providers = {
222223
"anthropic": lambda: AnthropicProvider(api_key=settings.anthropic_api_key),
223224
"openai": lambda: OpenAIProvider(
224225
api_key=settings.openai_api_key,
225226
base_url=settings.openai_base_url,
226227
ssl_verify=settings.minds_ssl_verify,
228+
api_version=api_version,
227229
),
228230
"openai-compatible": lambda: OpenAIProvider(
229231
api_key=settings.openai_api_key,
230232
base_url=settings.openai_base_url,
231233
ssl_verify=settings.minds_ssl_verify,
234+
api_version=api_version,
232235
),
233236
}
234237

anton/core/llm/openai.py

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from collections.abc import AsyncIterator
55

66
import openai
7+
from openai import AsyncAzureOpenAI
78

89
from .provider import (
910
ContextOverflowError,
@@ -175,6 +176,16 @@ def _translate_user_blocks(blocks: list[dict]) -> list[dict]:
175176
return result
176177

177178

179+
def _is_azure_endpoint(url: str | None) -> bool:
180+
"""Return True if the URL looks like an Azure OpenAI endpoint."""
181+
if not url:
182+
return False
183+
from urllib.parse import urlparse
184+
parsed = urlparse(url if "://" in url else f"https://{url}")
185+
host = (parsed.netloc or parsed.path).lower()
186+
return host.endswith(".openai.azure.com") or host.endswith(".cognitiveservices.azure.com")
187+
188+
178189
def build_chat_completion_kwargs(
179190
*,
180191
model: str,
@@ -202,28 +213,43 @@ def __init__(
202213
api_key: str | None = None,
203214
base_url: str | None = None,
204215
ssl_verify: bool = True,
216+
api_version: str | None = None,
205217
) -> None:
206218
self._api_key = api_key
207219
self._base_url = base_url
208220
self._ssl_verify = ssl_verify
221+
self._api_version = api_version
209222

210223
import httpx
211224

212-
kwargs = {}
213-
if api_key:
214-
kwargs["api_key"] = api_key
215-
if base_url:
216-
kwargs["base_url"] = base_url
217-
if not ssl_verify:
218-
kwargs["http_client"] = httpx.AsyncClient(verify=False)
219-
self._client = openai.AsyncOpenAI(**kwargs)
225+
if api_version and _is_azure_endpoint(base_url):
226+
# Azure OpenAI: use the dedicated client which handles deployment
227+
# URL construction and api-version automatically.
228+
azure_kwargs: dict = {"api_version": api_version}
229+
if api_key:
230+
azure_kwargs["api_key"] = api_key
231+
if base_url:
232+
azure_kwargs["azure_endpoint"] = base_url
233+
if not ssl_verify:
234+
azure_kwargs["http_client"] = httpx.AsyncClient(verify=False)
235+
self._client = AsyncAzureOpenAI(**azure_kwargs)
236+
else:
237+
kwargs: dict = {}
238+
if api_key:
239+
kwargs["api_key"] = api_key
240+
if base_url:
241+
kwargs["base_url"] = base_url
242+
if not ssl_verify:
243+
kwargs["http_client"] = httpx.AsyncClient(verify=False)
244+
self._client = openai.AsyncOpenAI(**kwargs)
220245

221246
def export_connection_info(self) -> ProviderConnectionInfo:
222247
return ProviderConnectionInfo(
223248
provider=self.name,
224249
api_key=self._api_key,
225250
base_url=self._base_url,
226251
ssl_verify=self._ssl_verify,
252+
api_version=self._api_version,
227253
)
228254

229255
async def complete(

anton/core/llm/provider.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ class ProviderConnectionInfo:
145145
api_key: str | None = field(default=None, repr=False)
146146
base_url: str | None = None
147147
ssl_verify: bool | None = None
148+
api_version: str | None = None # Azure api-version query param
148149

149150

150151
class LLMProvider(ABC):

anton/updater.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,16 @@ def check_and_update(console, settings) -> bool:
2323
2424
Returns True if an update was applied and the process should restart.
2525
"""
26+
import os
27+
2628
if settings.disable_autoupdates:
2729
return False
2830

31+
# Guard against infinite restart loops. _reexec() sets this before
32+
# replacing the process; the new process inherits it and skips the check.
33+
if os.environ.get("_ANTON_UPDATED"):
34+
return False
35+
2936
result: dict = {}
3037

3138
def _worker():

tests/test_openai_provider.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,86 @@ def test_from_settings_openai(self):
236236
assert isinstance(client, LLMClient)
237237
assert isinstance(client._planning_provider, OpenAIProvider)
238238
assert isinstance(client._coding_provider, OpenAIProvider)
239+
240+
241+
class TestAzureOpenAIProvider:
242+
def test_uses_async_azure_openai_when_api_version_set(self):
243+
"""When api_version is provided, AsyncAzureOpenAI must be used."""
244+
mock_azure_client = MagicMock()
245+
with patch("anton.core.llm.openai.openai"), \
246+
patch("anton.core.llm.openai.AsyncAzureOpenAI", return_value=mock_azure_client) as mock_cls:
247+
provider = OpenAIProvider(
248+
api_key="azure-key",
249+
base_url="https://myresource.cognitiveservices.azure.com",
250+
api_version="2024-12-01-preview",
251+
)
252+
mock_cls.assert_called_once()
253+
call_kwargs = mock_cls.call_args.kwargs
254+
assert call_kwargs["api_version"] == "2024-12-01-preview"
255+
assert call_kwargs["api_key"] == "azure-key"
256+
assert call_kwargs["azure_endpoint"] == "https://myresource.cognitiveservices.azure.com"
257+
assert provider._client is mock_azure_client
258+
259+
def test_uses_async_openai_when_no_api_version(self):
260+
"""Without api_version, the standard AsyncOpenAI client must be used."""
261+
mock_std_client = MagicMock()
262+
with patch("anton.core.llm.openai.openai") as mock_openai:
263+
mock_openai.AsyncOpenAI.return_value = mock_std_client
264+
provider = OpenAIProvider(api_key="sk-test", base_url="http://localhost:11434/v1")
265+
mock_openai.AsyncOpenAI.assert_called_once()
266+
assert provider._client is mock_std_client
267+
268+
def test_export_connection_info_includes_api_version(self):
269+
with patch("anton.core.llm.openai.openai"), \
270+
patch("anton.core.llm.openai.AsyncAzureOpenAI"):
271+
provider = OpenAIProvider(
272+
api_key="key",
273+
base_url="https://res.openai.azure.com",
274+
api_version="2024-12-01-preview",
275+
)
276+
info = provider.export_connection_info()
277+
assert info.api_version == "2024-12-01-preview"
278+
assert info.base_url == "https://res.openai.azure.com"
279+
280+
def test_from_settings_passes_api_version_to_provider(self):
281+
"""LLMClient.from_settings propagates openai_api_version to OpenAIProvider."""
282+
with patch("anton.core.llm.openai.openai"), \
283+
patch("anton.core.llm.openai.AsyncAzureOpenAI") as mock_azure_cls:
284+
settings = AntonSettings(
285+
planning_provider="openai-compatible",
286+
coding_provider="openai-compatible",
287+
planning_model="gpt-4.1-mini",
288+
coding_model="gpt-4.1-mini",
289+
openai_api_key="azure-key",
290+
openai_base_url="https://myresource.cognitiveservices.azure.com",
291+
openai_api_version="2024-12-01-preview",
292+
_env_file=None,
293+
)
294+
client = LLMClient.from_settings(settings)
295+
assert mock_azure_cls.called
296+
call_kwargs = mock_azure_cls.call_args.kwargs
297+
assert call_kwargs["api_version"] == "2024-12-01-preview"
298+
assert isinstance(client._planning_provider, OpenAIProvider)
299+
300+
async def test_azure_provider_complete_calls_chat_completions(self):
301+
"""Azure provider routes complete() through chat.completions just like standard."""
302+
mock_azure_client = AsyncMock()
303+
mock_azure_client.chat.completions.create = AsyncMock(
304+
return_value=_make_mock_response(content="azure response", prompt_tokens=8, completion_tokens=12)
305+
)
306+
with patch("anton.core.llm.openai.openai"), \
307+
patch("anton.core.llm.openai.AsyncAzureOpenAI", return_value=mock_azure_client):
308+
provider = OpenAIProvider(
309+
api_key="azure-key",
310+
base_url="https://myresource.cognitiveservices.azure.com",
311+
api_version="2024-12-01-preview",
312+
)
313+
result = await provider.complete(
314+
model="gpt-4.1-mini",
315+
system="be helpful",
316+
messages=[{"role": "user", "content": "hello"}],
317+
)
318+
assert result.content == "azure response"
319+
assert result.usage.input_tokens == 8
320+
assert result.usage.output_tokens == 12
321+
mock_azure_client.chat.completions.create.assert_awaited_once()

0 commit comments

Comments
 (0)