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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand Down
10 changes: 6 additions & 4 deletions s1a/decision_models/jev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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))
21 changes: 21 additions & 0 deletions s1a/decision_models/wire.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from __future__ import annotations

import asyncio
import math
import os
import time
from typing import Any
Expand All @@ -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"
Expand Down
29 changes: 29 additions & 0 deletions tests/test_decision_models_jev.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Loading