diff --git a/.env.example b/.env.example index 58acac1..38dd90d 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,7 @@ MODEL_NAME=google/gemini-2.5-flash # Jev through OpenRouter's /api/alpha/decisions with OPENROUTER_API_KEY: TYPESAFE_API_URL selects that backend. # TYPESAFE_API_URL=https://openrouter.ai/api/alpha/decisions # TYPESAFE_MODEL=typesafe/jev-1.13 +# S1A_DECISION_TIMEOUT_S=5 # seconds per decision; raise it (e.g. 30) for a slower local System One server # Any OpenAI-compatible endpoint instead of OpenRouter: # OPENAI_API_KEY= # OPENAI_BASE_URL=https://api.openai.com/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b1eca6..b92db68 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); ver ### Added +- `S1A_DECISION_TIMEOUT_S`: the deadline of one decision on the `jev` backend, 5 s when unset. A local System One + server behind `TYPESAFE_API_URL` can be slower than Jev: on Google Flights, OneJev-27B on an A100 takes about 3.7 s + a decision and more on the calendar page, so the 5 s deadline stopped every run at the eighth step; with 30 s it + completed the task. `docs/configuration.md`. - The MCP `decide` tool accepts `model="jev"|"laya"|"cua"`, defaulting to `jev`. Local backends use their optional extras and need no Jev API key. - `docs/benchmarks.md`: the Google Flights driver comparison rerun on 2026-09-23 from Poland, every arm three times on diff --git a/docs/browser-front.md b/docs/browser-front.md index 026ff92..a840936 100644 --- a/docs/browser-front.md +++ b/docs/browser-front.md @@ -85,7 +85,7 @@ browser-use/jev-ultrafast (MIT), whose observe-decide-act tick this policy follo | `MAX_PROBE_SETTLE_MS` | 1500 | `load(3 s) + settle + 1 s` last-resort must stay at least 1 s under the 30 s transport timeout | | `WAIT_SETTLE_BUDGET_MS` | 3000 | in-page wait one WAIT streak may spend before BLOCKED | | `ACTION_SETTLE_START_MS`, `ACTION_SETTLE_BUDGET_MS` | 250, 1000 | first and total post-action wait | - | `DECISIONS_TIMEOUT_S` | 5 | Jev answers in 0.4 to 1.3 s through the proxy; a dead connection must not stall a step; one transport retry | + | `DECISIONS_TIMEOUT_S` | 5 | Jev answers in 0.4 to 1.3 s through the proxy; a dead connection must not stall a step; one transport retry; `S1A_DECISION_TIMEOUT_S` overrides it for a slower System One server ([configuration.md](configuration.md)) | | `DECISION_ATTEMPTS` | 2 | one re-ask of the same request when an answer fails validation; a transport error is final | | `BATCH_ACTION_TIMEOUT_MS` | 2000 | a stamp lost to a re-render fails fast; the returned probe re-stamps | diff --git a/docs/configuration.md b/docs/configuration.md index 7c71622..58a69dc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -11,6 +11,7 @@ Variables can be exported in your shell or placed in a `.env` file at the root o | `TYPESAFE_API_KEY` | `jev` model | *(unset)* | API key for direct TypeSafe decisions endpoint (`https://api.typesafe.ai/v1/systemone`). | | `TYPESAFE_API_URL` | `jev` model | `https://openrouter.ai/api/alpha/decisions` | Endpoint URL for decisions; defaults to OpenRouter proxy, or can be overridden to a custom proxy URL. | | `TYPESAFE_MODEL` | `jev` model | `typesafe/jev-1.13` | Model identifier when proxying Jev decisions through OpenRouter. | +| `S1A_DECISION_TIMEOUT_S` | `jev` model | `5` | Seconds one decision may take, retries included. Raise it for a slower System One server behind `TYPESAFE_API_URL` (e.g. a local OneJev-27B or CLM-8B: `30`). | | `OPENROUTER_API_KEY` | `jev` model (proxy), chat model fallback | *(unset)* | OpenRouter API key, used for proxying Jev decisions or as a fallback for `LLM_API_KEY`. | | `OPENROUTER_BASE_URL` | chat model fallback | `https://openrouter.ai/api/v1` | Fallback base URL for the chat model when `LLM_BASE_URL` or `OPENAI_BASE_URL` is unset. | | `MODEL_NAME` | chat model (`llm` model, rethink planner, browser agent) | *(unset)* | Model identifier for the chat model (e.g. `google/gemini-2.5-flash` or `claude-fable-5-1`). | diff --git a/s1a/decision_models/jev.py b/s1a/decision_models/jev.py index fce6e39..af4a200 100644 --- a/s1a/decision_models/jev.py +++ b/s1a/decision_models/jev.py @@ -5,7 +5,7 @@ from s1a.decision_models.base import DecisionModel, JevTransport from s1a.decision_models.types import ChoiceQuestion, Json, NoulQuestion, Observation, Question, Reply, Usage -from s1a.decision_models.wire import DECISIONS_TIMEOUT_S, client_from_env, decisions_backend_from_env +from s1a.decision_models.wire import client_from_env, decisions_backend_from_env, decisions_timeout_from_env def jev_question(question: Question) -> Json: @@ -67,6 +67,8 @@ async def close(self) -> None: await self._transport.close() @classmethod - def from_env(cls, *, timeout_s: float = DECISIONS_TIMEOUT_S) -> "JevModel": - """TypeSafe directly or the OpenRouter proxy, whichever the environment names.""" - return cls(client_from_env(decisions_backend_from_env(), timeout_s=timeout_s)) + def from_env(cls, *, timeout_s: float | None = None) -> "JevModel": + """TypeSafe directly or the OpenRouter proxy, whichever the environment names; the decision deadline is + ``timeout_s`` when given, else ``S1A_DECISION_TIMEOUT_S``, else 5 s.""" + deadline = timeout_s if timeout_s is not None else decisions_timeout_from_env() + return cls(client_from_env(decisions_backend_from_env(), timeout_s=deadline)) diff --git a/s1a/decision_models/wire.py b/s1a/decision_models/wire.py index b561cf8..e69284e 100644 --- a/s1a/decision_models/wire.py +++ b/s1a/decision_models/wire.py @@ -8,6 +8,7 @@ from __future__ import annotations import asyncio +import math import os import time from typing import Any @@ -31,6 +32,26 @@ _MAX_RETRY_AFTER_S = 5.0 +def decisions_timeout_from_env() -> float: + """The deadline for one decision, retries included: ``S1A_DECISION_TIMEOUT_S`` when set, else + ``DECISIONS_TIMEOUT_S``. A local System One server (OneJev, CLM) behind ``TYPESAFE_API_URL`` can take several + seconds on a page with many options, past the 5 s that fits Jev.""" + raw = (os.getenv("S1A_DECISION_TIMEOUT_S") or "").strip() + if not raw: + return DECISIONS_TIMEOUT_S + try: + value = float(raw) + except ValueError: + value = 0.0 + # float() also reads nan, inf and 1e9999 (inf): nan passes `<= 0` and poisons the deadline, inf removes it + if not math.isfinite(value) or value <= 0: + raise build_error( + StatusCode.MODEL_SERVICE_CONFIG_ERROR, + error_msg=f"S1A_DECISION_TIMEOUT_S must be a finite, positive number of seconds, not {raw!r}", + ) + return value + + def decisions_backend_from_env() -> str: """``typesafe`` when a TypeSafe key is set and no proxy URL overrides it; otherwise the OpenRouter proxy.""" return "typesafe" if os.getenv("TYPESAFE_API_KEY") and not os.getenv("TYPESAFE_API_URL") else "openrouter" diff --git a/tests/test_decision_models_jev.py b/tests/test_decision_models_jev.py index 01e6930..9db0aa3 100644 --- a/tests/test_decision_models_jev.py +++ b/tests/test_decision_models_jev.py @@ -300,3 +300,32 @@ def test_no_key_is_a_config_error(self) -> None: with self.assertRaises(BaseError) as caught: JevModel.from_env() self.assertEqual(caught.exception.status, StatusCode.MODEL_SERVICE_CONFIG_ERROR) + + +class TestDecisionTimeout(TestCase): + """``S1A_DECISION_TIMEOUT_S`` sets the deadline of one decision; Jev keeps 5 s when it is not set.""" + + ENV = {"TYPESAFE_API_KEY": "", "OPENROUTER_API_KEY": "r", "TYPESAFE_MODEL": "", "TYPESAFE_API_URL": ""} + + def test_the_deadline_stays_5_s_without_the_variable(self) -> None: + with patch.dict(os.environ, {**self.ENV, "S1A_DECISION_TIMEOUT_S": ""}): + decision_model = JevModel.from_env() + self.assertEqual(decision_model._transport._timeout_s, wire.DECISIONS_TIMEOUT_S) + + def test_the_variable_sets_the_deadline(self) -> None: + with patch.dict(os.environ, {**self.ENV, "S1A_DECISION_TIMEOUT_S": "30"}): + decision_model = JevModel.from_env() + self.assertEqual(decision_model._transport._timeout_s, 30.0) + + def test_an_explicit_deadline_wins_over_the_variable(self) -> None: + with patch.dict(os.environ, {**self.ENV, "S1A_DECISION_TIMEOUT_S": "30"}): + decision_model = JevModel.from_env(timeout_s=2.0) + self.assertEqual(decision_model._transport._timeout_s, 2.0) + + def test_a_value_that_is_not_a_finite_positive_number_is_a_config_error(self) -> None: + for raw in ("abc", "0", "-3", "nan", "inf", "-inf", "1e9999"): + with self.subTest(raw=raw), patch.dict(os.environ, {**self.ENV, "S1A_DECISION_TIMEOUT_S": raw}): + with self.assertRaises(BaseError) as caught: + JevModel.from_env() + self.assertEqual(caught.exception.status, StatusCode.MODEL_SERVICE_CONFIG_ERROR) + self.assertIn("S1A_DECISION_TIMEOUT_S", str(caught.exception))