Skip to content
Open
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
23 changes: 23 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -510,3 +510,26 @@ def test_close_without_chat_use(self, client):
with patch.object(httpx.Client, "close"):
client.close()
assert client._chat is None


class TestModelCallRetries:
"""Navigator model calls retry transient failures; the depth is caller-visible.

The eval that motivated this lost 20 of 108 tasks to three ~2-minute bursts of
gateway `upstream_error` 5xx: the bundled client's own default of 2 retries spans
under ~2s of backoff, so a brief incident ends a long agent run outright.
"""

def test_default_depth_is_the_sdk_default_not_the_vendor_default(self, client):
from yutori.config import DEFAULT_MAX_RETRIES

assert client.chat._openai_client.max_retries == DEFAULT_MAX_RETRIES
assert DEFAULT_MAX_RETRIES > 2 # the openai client's own default

def test_caller_can_raise_the_depth(self):
deep = YutoriClient(api_key="yt-test", max_retries=9)
assert deep.chat._openai_client.max_retries == 9

def test_caller_can_disable_retries(self):
none = YutoriClient(api_key="yt-test", max_retries=0)
assert none.chat._openai_client.max_retries == 0
28 changes: 28 additions & 0 deletions tests/test_navigator_n2.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import asyncio
import base64
import copy
import io
import json
import time
Expand Down Expand Up @@ -1260,3 +1261,30 @@ def test_converter_preserves_plain_string_assistant_turns():
]
)
assert {"role": "assistant", "content": "earlier answer"} in messages


@pytest.mark.asyncio
async def test_completion_request_is_public_wrapup_surface() -> None:
"""completion_request() returns the actor's exact next request — windowed messages,
sampling fields, chaining — with optional harness-owned extra messages appended,
without advancing the loop or mutating the trajectory."""
agent = N2ComputerAgent(
computer=FakeComputer(),
tool_set=TOOL_SET_COMPUTER_USE_HYBRID_BATCH,
completions=FakeCompletions([_turn({"content": "Done.", "tool_calls": []})]),
screenshot_delay=0,
)
async for _step in agent.run("task"):
pass
before = copy.deepcopy(agent.trajectory)
nudge = {"role": "user", "content": [{"type": "text", "text": "Stop here. Summarize."}]}
request = agent.completion_request([nudge])
assert request["model"] == agent.model
assert request["tool_set"] == agent.tool_set
assert request["max_completion_tokens"] == agent.max_completion_tokens
assert request["messages"][-1] == nudge
# the trajectory itself renders just before the nudge, exactly as the loop would send it
assert request["messages"][:-1] == agent._prepare_completion_messages(agent.trajectory)
assert agent.trajectory == before
if agent.last_request_id is not None:
assert request["extra_body"]["prev_request_id"] == agent.last_request_id
18 changes: 13 additions & 5 deletions yutori/_async/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from openai.types.chat import ChatCompletion, ChatCompletionMessageParam

from .._http import apply_chat_extra_body
from ..config import DEFAULT_MAX_RETRIES
from ..navigator.models import NAVIGATOR_N1_5_MODEL


Expand Down Expand Up @@ -65,13 +66,20 @@ class AsyncChatNamespace:
"""Async namespace for Navigator API operations (pixels-to-actions LLM).

Requests go through the bundled OpenAI client, which retries failures
(connection errors, timeouts, 429/5xx) twice by default; this is not
configurable through the SDK surface. The other SDK namespaces never
retry.
(connection errors, timeouts, 429/5xx) with exponential backoff, honoring
a ``Retry-After`` header when the server sends one. The depth is
``max_retries`` (default :data:`~yutori.config.DEFAULT_MAX_RETRIES`), set
on the client. The other SDK namespaces never retry.
"""

def __init__(self, base_url: str, api_key: str, timeout: float) -> None:
self._openai_client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
def __init__(
self,
base_url: str,
api_key: str,
timeout: float,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> None:
self._openai_client = AsyncOpenAI(base_url=base_url, api_key=api_key, timeout=timeout, max_retries=max_retries)
self.completions = AsyncChatCompletions(self._openai_client)

async def close(self) -> None:
Expand Down
18 changes: 13 additions & 5 deletions yutori/_sync/chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from openai.types.chat import ChatCompletion, ChatCompletionMessageParam

from .._http import apply_chat_extra_body
from ..config import DEFAULT_MAX_RETRIES
from ..navigator.models import NAVIGATOR_N1_5_MODEL


Expand Down Expand Up @@ -65,13 +66,20 @@ class ChatNamespace:
"""Namespace for Navigator API operations (pixels-to-actions LLM).

Requests go through the bundled OpenAI client, which retries failures
(connection errors, timeouts, 429/5xx) twice by default; this is not
configurable through the SDK surface. The other SDK namespaces never
retry.
(connection errors, timeouts, 429/5xx) with exponential backoff, honoring
a ``Retry-After`` header when the server sends one. The depth is
``max_retries`` (default :data:`~yutori.config.DEFAULT_MAX_RETRIES`), set
on the client. The other SDK namespaces never retry.
"""

def __init__(self, base_url: str, api_key: str, timeout: float) -> None:
self._openai_client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout)
def __init__(
self,
base_url: str,
api_key: str,
timeout: float,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> None:
self._openai_client = OpenAI(base_url=base_url, api_key=api_key, timeout=timeout, max_retries=max_retries)
self.completions = ChatCompletions(self._openai_client)

def close(self) -> None:
Expand Down
9 changes: 7 additions & 2 deletions yutori/async_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
)
from ._http import _AsyncBaseNamespace, build_query_params
from .auth.credentials import require_api_key
from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url
from .config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url


class AsyncYutoriClient(_AsyncBaseNamespace):
Expand Down Expand Up @@ -45,6 +45,7 @@ def __init__(
*,
base_url: str = DEFAULT_BASE_URL,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> None:
"""Initialize the async Yutori client.

Expand All @@ -53,6 +54,9 @@ def __init__(
reads from the YUTORI_API_KEY environment variable.
base_url: API base URL (default: https://api.yutori.com/v1).
timeout: Request timeout in seconds (default: 30).
max_retries: How many times a Navigator (``client.chat``) model call is
retried on a connection error, timeout, 429 or 5xx, with exponential
backoff (default: 4). Other namespaces never retry.

Raises:
AuthenticationError: If no API key is provided or found in environment.
Expand All @@ -66,6 +70,7 @@ def __init__(
self.browsing = AsyncBrowsingNamespace(self._client, self._base_url, self._api_key)
self.research = AsyncResearchNamespace(self._client, self._base_url, self._api_key)
self._timeout = timeout
self._max_retries = max_retries
self._chat: AsyncChatNamespace | None = None

async def get_usage(self, *, period: str | None = None) -> dict[str, Any]:
Expand All @@ -92,7 +97,7 @@ def chat(self) -> AsyncChatNamespace:
AsyncYutoriClient, even for callers that never use chat completions.
"""
if self._chat is None:
self._chat = AsyncChatNamespace(self._base_url, self._api_key, self._timeout)
self._chat = AsyncChatNamespace(self._base_url, self._api_key, self._timeout, self._max_retries)
return self._chat

async def close(self) -> None:
Expand Down
9 changes: 7 additions & 2 deletions yutori/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from ._http import _SyncBaseNamespace, build_query_params
from ._sync import BrowsingNamespace, ChatNamespace, ResearchNamespace, ScoutsNamespace
from .auth.credentials import require_api_key
from .config import DEFAULT_BASE_URL, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url
from .config import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT_SECONDS, sanitize_base_url


class YutoriClient(_SyncBaseNamespace):
Expand All @@ -35,6 +35,7 @@ def __init__(
*,
base_url: str = DEFAULT_BASE_URL,
timeout: float = DEFAULT_TIMEOUT_SECONDS,
max_retries: int = DEFAULT_MAX_RETRIES,
) -> None:
"""Initialize the Yutori client.

Expand All @@ -43,6 +44,9 @@ def __init__(
reads from the YUTORI_API_KEY environment variable.
base_url: API base URL (default: https://api.yutori.com/v1).
timeout: Request timeout in seconds (default: 30).
max_retries: How many times a Navigator (``client.chat``) model call is
retried on a connection error, timeout, 429 or 5xx, with exponential
backoff (default: 4). Other namespaces never retry.

Raises:
AuthenticationError: If no API key is provided or found in environment.
Expand All @@ -56,6 +60,7 @@ def __init__(
self.browsing = BrowsingNamespace(self._client, self._base_url, self._api_key)
self.research = ResearchNamespace(self._client, self._base_url, self._api_key)
self._timeout = timeout
self._max_retries = max_retries
self._chat: ChatNamespace | None = None

def get_usage(self, *, period: str | None = None) -> dict[str, Any]:
Expand All @@ -82,7 +87,7 @@ def chat(self) -> ChatNamespace:
for callers that never use chat completions.
"""
if self._chat is None:
self._chat = ChatNamespace(self._base_url, self._api_key, self._timeout)
self._chat = ChatNamespace(self._base_url, self._api_key, self._timeout, self._max_retries)
return self._chat

def close(self) -> None:
Expand Down
11 changes: 11 additions & 0 deletions yutori/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@
DEFAULT_BASE_URL = "https://api.yutori.com/v1"
DEFAULT_TIMEOUT_SECONDS = 30.0

# Model-call retries for the Navigator (chat) namespace. The bundled OpenAI client retries
# connection errors, timeouts and 429/5xx with exponential backoff, honoring `Retry-After`.
#
# 4 rather than the client's own default of 2: a long-horizon agent run issues hundreds of
# sequential model calls, so a single unretried upstream blip ends the whole run, and two
# retries span under ~2s of backoff. A brief gateway incident (measured: three ~2-minute
# bursts of `upstream_error` on 2026-08-31) outlasts that. Raise `max_retries` further for
# unattended batch work; the ceiling is a caller's patience, not correctness, since every
# retried request is idempotent.
DEFAULT_MAX_RETRIES = 4


def sanitize_base_url(url: str) -> str:
"""Ensure the base URL never ends with a trailing slash."""
Expand Down
33 changes: 27 additions & 6 deletions yutori/navigator/n2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1280,6 +1280,31 @@ def _prepare_completion_messages(self, items: list[dict[str, Any]]) -> list[dict
)
return completion_messages

def completion_request(
self, extra_messages: "list[dict[str, Any]] | None" = None, *, items: "list[dict[str, Any]] | None" = None
) -> dict[str, Any]:
"""The actor's next Chat Completions request for the current trajectory.

Returns exactly what the loop itself sends — system prompt, image-windowed
messages, sampling fields, and request chaining — optionally with ``extra_messages``
(chat-format) appended after the trajectory. Useful for harness-owned turns that
must not advance the loop or execute tools, such as a step-cap "stop and
summarize" wrap-up: send it yourself with the same client,
``await client.chat.completions.create(**agent.completion_request([nudge]))``.
The call and its response stay the caller's own; the trajectory is not changed.
``items`` overrides the rendered trajectory (the loop's own steps pass their
in-flight working set).
"""
request = self._completion_request_kwargs()
# Historical merge order: callers should not pass ``messages`` through
# completion_kwargs, but it previously won if they did.
request.setdefault("messages", self._prepare_completion_messages(self.trajectory if items is None else items))
if extra_messages:
request["messages"] = list(request["messages"]) + copy.deepcopy(list(extra_messages))
if self.last_request_id is not None:
request["extra_body"] = {**(request.get("extra_body") or {}), "prev_request_id": self.last_request_id}
return request

def _completion_request_kwargs(self) -> dict[str, Any]:
"""Return resolved actor call fields other than messages and chaining."""

Expand All @@ -1304,7 +1329,8 @@ async def _await_completion(self, awaitable: Awaitable[Any]) -> Any:
return await _await_model_response(self.computer, awaitable)

async def _predict_step(self, items: list[dict[str, Any]]) -> dict[str, Any]:
completion_messages = self._prepare_completion_messages(items)
api_kwargs = self.completion_request(items=items)
completion_messages = api_kwargs["messages"]

latest_url = latest_image_url(completion_messages)
if latest_url is None:
Expand All @@ -1323,11 +1349,6 @@ async def _predict_step(self, items: list[dict[str, Any]]) -> dict[str, Any]:
else:
native_width, native_height = image_dimensions(latest_url)

api_kwargs = self._completion_request_kwargs()
# Preserve the historical merge order: callers should not pass
# ``messages`` through completion_kwargs, but it previously won if they did.
api_kwargs.setdefault("messages", completion_messages)

for attempt in (0, 1):
if self.last_request_id is not None:
# Echo the previous response's request_id so the platform links the
Expand Down