mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Portal free user vision fix + flux3 polling improvements (#75448)
* flux3 polling improvments * poll gap to 4s * back to 5s * vision model fix * minor fix
This commit is contained in:
parent
44e5641dac
commit
126ff7071b
7 changed files with 758 additions and 105 deletions
|
|
@ -5256,7 +5256,16 @@ def resolve_provider_client(
|
|||
# sent to Codex after the main lane fell back to gpt-5.5). Let _resolve_auto()
|
||||
# return the actual current runtime model when the caller did not explicitly
|
||||
# request one. (# compression-current-model)
|
||||
if not model and provider != "auto":
|
||||
#
|
||||
# Nous + vision is the one carve-out: the branch below resolves its model
|
||||
# from the Portal's tier-aware vision recommendation (``_try_nous(vision=
|
||||
# True)``), and ``final_model = model or default`` means anything pre-filled
|
||||
# here wins over that. The main chat model is routinely text-only (e.g. a
|
||||
# ``:free`` chat SKU), so pre-filling it sends the image to a model that
|
||||
# cannot accept one and the Portal 404s. Leave ``model`` unset and let the
|
||||
# Portal slot through; only an explicit caller model may override it.
|
||||
_nous_portal_vision = provider == "nous" and is_vision
|
||||
if not model and provider != "auto" and not _nous_portal_vision:
|
||||
model = _get_aux_model_for_provider(provider) or _read_main_model_for_aux() or model
|
||||
|
||||
def _needs_codex_wrap(client_obj, base_url_str: str, model_str: str) -> bool:
|
||||
|
|
@ -6179,10 +6188,17 @@ def resolve_vision_provider_client(
|
|||
# DeepSeek-V4-Flash default) and _main_model_supports_vision can't be
|
||||
# trusted to catch that. Only fall back to the chat model when no
|
||||
# provider default is available (catalog unreachable).
|
||||
vision_model = _resolve_provider_vision_default(main_provider) or main_model
|
||||
provider_vision_default = _resolve_provider_vision_default(main_provider)
|
||||
vision_model = provider_vision_default or main_model
|
||||
if main_provider == "nous":
|
||||
# Nous resolves its vision model from the Portal's tier-aware
|
||||
# recommended-models slots inside _try_nous(vision=True).
|
||||
# Passing the chat model here overrides that pick, so a
|
||||
# text-only chat default (e.g. a `:free` chat SKU) receives the
|
||||
# image and the upstream rejects it with a 404. Only an
|
||||
# explicit auxiliary.vision.model may override the Portal.
|
||||
sync_client, default_model = _resolve_strict_vision_backend(
|
||||
main_provider, vision_model
|
||||
main_provider, resolved_model or provider_vision_default
|
||||
)
|
||||
if sync_client is not None:
|
||||
logger.info(
|
||||
|
|
|
|||
|
|
@ -2149,11 +2149,11 @@ def _exempt_explicit_platform_native(
|
|||
#: Landing late — or leaving an entry here for a second release — converts a
|
||||
#: back-fill into a stuck checkbox.
|
||||
#:
|
||||
#: Not gated on a Nous subscription here: the six ``bfl_flux3_*`` tools carry
|
||||
#: ``check_fn=check_bfl_requirements`` (logged in AND paid), so an enabled
|
||||
#: toolset still ships zero schemas to a user without paid portal access — the
|
||||
#: same split Home Assistant uses. Probing the portal from this path would put
|
||||
#: a network call on every CLI start, gateway session and cron tick.
|
||||
#: Not gated on a Nous sign-in here: the six ``bfl_flux3_*`` tools carry
|
||||
#: ``check_fn=check_bfl_requirements``, so an enabled toolset still ships zero
|
||||
#: schemas to a user with no Nous credential — the same split Home Assistant
|
||||
#: uses. Probing the portal from this path would put a network call on every
|
||||
#: CLI start, gateway session and cron tick.
|
||||
_RECENTLY_SHIPPED_TOOLSETS = frozenset({"bfl"})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,129 @@ class TestResolveVisionMainFirst:
|
|||
|
||||
|
||||
|
||||
@staticmethod
|
||||
def _stub_nous_portal(seen: dict):
|
||||
"""Stub the Nous network boundary, keeping the resolution chain real.
|
||||
|
||||
Returns a ``_try_nous`` replacement that answers with the Portal's
|
||||
tier-aware slots: a vision model for ``vision=True``, the text chat
|
||||
default otherwise.
|
||||
"""
|
||||
nous_client = MagicMock()
|
||||
nous_client.api_key = "jwt-test"
|
||||
nous_client.base_url = "https://inference-api.nousresearch.com/v1"
|
||||
|
||||
def fake_try_nous(vision=False):
|
||||
seen["vision"] = vision
|
||||
return nous_client, (
|
||||
"stepfun/step-3.7-flash:free" if vision else "tencent/hy3:free"
|
||||
)
|
||||
|
||||
return nous_client, fake_try_nous
|
||||
|
||||
def test_nous_main_vision_uses_portal_pick_not_text_chat_model(self):
|
||||
"""Nous main → vision runs the Portal's vision slot, not the chat model.
|
||||
|
||||
A Nous chat default is routinely text-only (e.g. a ``:free`` chat SKU).
|
||||
Letting it reach the vision lane means the image goes to a model that
|
||||
cannot accept one and the Portal 404s. Only the Nous network boundary
|
||||
is stubbed — the strict vision backend, the provider router, and its
|
||||
missing-model pre-fill all run for real, because that pre-fill is where
|
||||
the chat model used to clobber the Portal's pick.
|
||||
"""
|
||||
seen: dict = {}
|
||||
nous_client, fake_try_nous = self._stub_nous_portal(seen)
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nous",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="tencent/hy3:free",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", None, None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_nous", side_effect=fake_try_nous,
|
||||
):
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "nous"
|
||||
assert client is nous_client
|
||||
assert seen["vision"] is True
|
||||
assert model == "stepfun/step-3.7-flash:free"
|
||||
|
||||
def test_nous_main_vision_honours_explicit_vision_model(self):
|
||||
"""An explicit auxiliary.vision.model still overrides the Portal pick."""
|
||||
seen: dict = {}
|
||||
_nous_client, fake_try_nous = self._stub_nous_portal(seen)
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nous",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="tencent/hy3:free",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("auto", "qwen/qwen3-vl-8b-instruct", None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_nous", side_effect=fake_try_nous,
|
||||
):
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, _client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "nous"
|
||||
assert model == "qwen/qwen3-vl-8b-instruct"
|
||||
|
||||
def test_nous_explicit_vision_provider_also_skips_chat_model(self):
|
||||
"""``auxiliary.vision.provider: nous`` takes the same Portal pick.
|
||||
|
||||
The explicit-provider branch reaches the strict vision backend with no
|
||||
model too, so it has to resolve the same way the auto branch does.
|
||||
"""
|
||||
seen: dict = {}
|
||||
nous_client, fake_try_nous = self._stub_nous_portal(seen)
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nous",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="tencent/hy3:free",
|
||||
), patch(
|
||||
"agent.auxiliary_client._resolve_task_provider_model",
|
||||
return_value=("nous", None, None, None, None),
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_nous", side_effect=fake_try_nous,
|
||||
):
|
||||
from agent.auxiliary_client import resolve_vision_provider_client
|
||||
|
||||
provider, client, model = resolve_vision_provider_client()
|
||||
|
||||
assert provider == "nous"
|
||||
assert client is nous_client
|
||||
assert model == "stepfun/step-3.7-flash:free"
|
||||
|
||||
def test_nous_text_aux_still_uses_main_chat_model(self):
|
||||
"""The vision carve-out must not leak into text aux resolution.
|
||||
|
||||
Text auxiliary work on a Nous main deliberately keeps the user's chat
|
||||
model rather than dropping to the Portal's cheap default.
|
||||
"""
|
||||
seen: dict = {}
|
||||
_nous_client, fake_try_nous = self._stub_nous_portal(seen)
|
||||
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider", return_value="nous",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="tencent/hy3:free",
|
||||
), patch(
|
||||
"agent.auxiliary_client._try_nous", side_effect=fake_try_nous,
|
||||
):
|
||||
from agent.auxiliary_client import resolve_provider_client
|
||||
|
||||
_client, model = resolve_provider_client("nous")
|
||||
|
||||
assert model == "tencent/hy3:free"
|
||||
|
||||
def test_copilot_vision_sets_vision_header(self, monkeypatch):
|
||||
"""Copilot vision requests include the header required for vision routing."""
|
||||
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghu_test-token")
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
|
@ -15,6 +16,11 @@ GATEWAY = "https://tool-gateway.example.com"
|
|||
BASE_URL = f"{GATEWAY}/api/bfl"
|
||||
UPLOAD_PATH = "/api/uploads/bfl"
|
||||
|
||||
# The shipped pacing, read before the autouse fixture below rewrites it to
|
||||
# something the tests can spend in an instant.
|
||||
_DEFAULT_POLL_BUDGET_SECONDS = flux3._POLL_BUDGET_SECONDS
|
||||
_DEFAULT_CALL_BACKSTOP_SECONDS = flux3._CALL_BACKSTOP_SECONDS
|
||||
|
||||
_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
|
@ -127,6 +133,25 @@ def _record_sleep(sink):
|
|||
return _sleep
|
||||
|
||||
|
||||
def _stepped_clock(look_seconds):
|
||||
"""A monotonic clock on which every look appears to take `look_seconds`.
|
||||
|
||||
The poll loop reads the clock twice per look — once before the request and
|
||||
once after — so advancing on every second read charges a look exactly that
|
||||
much budget without spending any real time. Substituted for the module's
|
||||
whole ``time`` reference rather than patching ``time.monotonic`` globally,
|
||||
which would hand the same jumping clock to the event loop underneath.
|
||||
"""
|
||||
reads = {"n": 0}
|
||||
|
||||
def _monotonic():
|
||||
value = (reads["n"] // 2) * look_seconds
|
||||
reads["n"] += 1
|
||||
return value
|
||||
|
||||
return _monotonic
|
||||
|
||||
|
||||
def _call(handler, args, response, headers=None):
|
||||
"""Invoke a handler with the transport stubbed; returns (parsed, requests)."""
|
||||
sink = []
|
||||
|
|
@ -146,25 +171,58 @@ class TestGating:
|
|||
with patch.object(flux3, "managed_vendor_endpoints", return_value=None):
|
||||
assert flux3.check_bfl_requirements() is False
|
||||
|
||||
def test_hidden_without_paid_service_access(self):
|
||||
# The free tool pool does not fund BFL, so a pool-only user must never
|
||||
# see the tools rather than see them and be refused.
|
||||
account = SimpleNamespace(logged_in=True, paid_service_access=False, tool_gateway_entitled=True)
|
||||
with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account):
|
||||
assert flux3.check_bfl_requirements() is False
|
||||
|
||||
def test_hidden_when_logged_out(self):
|
||||
account = SimpleNamespace(logged_in=False, paid_service_access=False)
|
||||
with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account):
|
||||
assert flux3.check_bfl_requirements() is False
|
||||
|
||||
def test_visible_for_a_paid_portal_account(self):
|
||||
account = SimpleNamespace(logged_in=True, paid_service_access=True)
|
||||
with patch("hermes_cli.nous_account.get_nous_portal_account_info", return_value=account):
|
||||
def test_visible_to_any_signed_in_account_whatever_its_entitlement(self):
|
||||
# Entitlement is the gateway's ruling, and it states its reason in a
|
||||
# refusal the model can act on. Deciding it here as well could only
|
||||
# hide the tools from someone the server would have served, so the
|
||||
# portal's entitlement view must not be consulted at all.
|
||||
with patch.object(flux3, "peek_nous_access_token", return_value="nous-token"), \
|
||||
patch(
|
||||
"hermes_cli.nous_account.get_nous_portal_account_info",
|
||||
side_effect=AssertionError("entitlement must not gate visibility"),
|
||||
):
|
||||
assert flux3.check_bfl_requirements() is True
|
||||
|
||||
def test_fails_closed_when_the_account_probe_raises(self):
|
||||
with patch("hermes_cli.nous_account.get_nous_portal_account_info", side_effect=RuntimeError("portal down")):
|
||||
def test_hidden_without_a_nous_credential(self):
|
||||
# The gateway takes a Nous bearer and nothing else, so with no token
|
||||
# every call could only ever answer "sign in" — six schemas on every
|
||||
# API call for something that cannot work.
|
||||
with patch.object(flux3, "peek_nous_access_token", return_value=None):
|
||||
assert flux3.check_bfl_requirements() is False
|
||||
|
||||
def test_a_profile_sees_a_credential_held_at_the_global_root(self, tmp_path, monkeypatch):
|
||||
# A profile that was never logged into separately still calls the
|
||||
# gateway with the root login, because the transport's refresh path
|
||||
# reads that same global fallback. Probing only the profile's own store
|
||||
# would hide the tools from someone whose calls would have worked.
|
||||
#
|
||||
# Exercised through the real auth store rather than a stub: the
|
||||
# fallback is the whole point of the test, and it lives in
|
||||
# hermes_cli.auth, not here.
|
||||
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||
root = tmp_path / "root"
|
||||
(root / "profiles" / "work").mkdir(parents=True)
|
||||
(root / "auth.json").write_text(
|
||||
json.dumps({"version": 1, "providers": {"nous": {"access_token": "root-token"}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root / "profiles" / "work"))
|
||||
|
||||
# The profile's own store is empty, so this passes only via the
|
||||
# global-root fallback — without which the tools would be hidden.
|
||||
assert flux3.peek_nous_access_token() is None
|
||||
assert flux3.check_bfl_requirements() is True
|
||||
|
||||
def test_the_credential_probe_never_forces_a_token_refresh(self, monkeypatch):
|
||||
# check_fn runs on every CLI start, gateway session and cron tick, so
|
||||
# it reads a cached credential rather than sitting on a synchronous
|
||||
# OAuth refresh.
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
with patch.object(flux3, "read_nous_access_token", side_effect=AssertionError("refreshed")):
|
||||
assert flux3.check_bfl_requirements() is True
|
||||
|
||||
def test_fails_closed_when_the_credential_probe_raises(self):
|
||||
with patch.object(flux3, "peek_nous_access_token", side_effect=RuntimeError("auth store unreadable")):
|
||||
assert flux3.check_bfl_requirements() is False
|
||||
|
||||
|
||||
|
|
@ -263,8 +321,20 @@ class TestSubmitTransport:
|
|||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_poll_wait(monkeypatch):
|
||||
"""Keep the in-call wait out of the test clock."""
|
||||
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 0)
|
||||
"""Pace the in-call poll loop off the test clock, at two looks per call.
|
||||
|
||||
The handler counts its budget rather than reading a clock, so a gap and a
|
||||
budget in a fixed ratio give a deterministic number of looks with no fake
|
||||
clock: a budget of two gaps spends one wait and takes two looks, which is
|
||||
the smallest loop that can still show a job finishing between looks.
|
||||
"""
|
||||
monkeypatch.setattr(flux3, "_POLL_GAP_SECONDS", 1.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 2.0)
|
||||
|
||||
async def _instant(_seconds):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(flux3.asyncio, "sleep", _instant)
|
||||
|
||||
|
||||
class TestPollTransport:
|
||||
|
|
@ -282,7 +352,8 @@ class TestPollTransport:
|
|||
def test_a_running_job_is_waited_out_inside_the_call(self, monkeypatch):
|
||||
# A model has no clock, so telling it to pause produced a burst of polls
|
||||
# instead of a paced one. The wait lives here where it cannot be skipped.
|
||||
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_GAP_SECONDS", 45.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 90.0)
|
||||
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
|
||||
|
||||
slept = []
|
||||
|
|
@ -293,11 +364,26 @@ class TestPollTransport:
|
|||
assert sum(slept) == 45.0
|
||||
assert parsed["details"]["status"] == "Generating"
|
||||
|
||||
def test_the_loop_keeps_looking_until_its_budget_is_spent(self, monkeypatch):
|
||||
# The job endpoint answers at once, so a call that looked a fixed twice
|
||||
# spent almost none of the time it was allowed and handed control back
|
||||
# to the model four or five times per generation. One call now covers
|
||||
# the whole budget, and the model decides to keep waiting once.
|
||||
monkeypatch.setattr(flux3, "_POLL_GAP_SECONDS", 10.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 50.0)
|
||||
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
|
||||
|
||||
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running)
|
||||
|
||||
assert len(requests) == 5, "five looks spaced by four ten-second gaps"
|
||||
assert parsed["details"]["status"] == "Generating"
|
||||
|
||||
def test_the_wait_is_answerable_to_a_stop(self, monkeypatch):
|
||||
# Nothing outside the tool can end a call that has already started —
|
||||
# the executor only checks for an interrupt between tools — so /stop
|
||||
# has to land inside the wait rather than at the end of it.
|
||||
monkeypatch.setattr(flux3, "_POLL_FOLLOW_UP_WAIT_SECONDS", 45.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_GAP_SECONDS", 45.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 200.0)
|
||||
from tools import interrupt as interrupt_module
|
||||
|
||||
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
|
||||
|
|
@ -327,9 +413,9 @@ class TestPollTransport:
|
|||
assert len(requests) == 2
|
||||
assert parsed["result"] == "That job failed."
|
||||
|
||||
def test_a_refusal_is_returned_immediately_rather_than_waited_on(self):
|
||||
# A 429 carries its own retry guidance; sleeping on it would only delay
|
||||
# showing the model what to do, and spend the poll budget twice.
|
||||
def test_a_refusal_without_a_stated_wait_is_returned_immediately(self):
|
||||
# A dead job or a bad id has nothing to wait for; sleeping on it would
|
||||
# only delay showing the model what to do.
|
||||
response = _FakeResponse(429, {"error": {"message": "Too many polls. Wait 30 seconds."}})
|
||||
|
||||
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
|
||||
|
|
@ -337,6 +423,202 @@ class TestPollTransport:
|
|||
assert len(requests) == 1
|
||||
assert "Too many polls" in parsed["error"]
|
||||
|
||||
def test_a_throttle_is_waited_out_inside_the_call(self, monkeypatch):
|
||||
# Handing a throttle back ends the call, and the model it lands on has
|
||||
# no clock — it asks again at once, tightening the loop that tripped the
|
||||
# limit. The stated wait is taken here instead.
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 100.0)
|
||||
throttled = _FakeResponse(
|
||||
429,
|
||||
{"error": {"message": "Too many polls.", "details": {"retryAfterSeconds": 30}}},
|
||||
)
|
||||
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {}, "guidance": "Done."})
|
||||
|
||||
slept = []
|
||||
with patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)):
|
||||
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, [throttled, done])
|
||||
|
||||
assert len(requests) == 2, "the loop survives a throttle"
|
||||
assert sum(slept) == 30.0, "and waits exactly as long as it was asked to"
|
||||
assert parsed["result"] == "Done."
|
||||
|
||||
def test_a_throttle_never_polls_faster_than_the_loop_s_own_cadence(self, monkeypatch):
|
||||
# The gateway's number is a floor on politeness, not a licence to
|
||||
# hammer: a small or malformed-but-positive wait must not turn the loop
|
||||
# into a tight one against an endpoint that just asked us to slow down.
|
||||
monkeypatch.setattr(flux3, "_POLL_GAP_SECONDS", 10.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 100.0)
|
||||
throttled = _FakeResponse(
|
||||
429,
|
||||
{"error": {"message": "Slow down.", "details": {"retryAfterSeconds": 0.001}}},
|
||||
)
|
||||
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {}, "guidance": "Done."})
|
||||
|
||||
slept = []
|
||||
with patch.object(flux3.asyncio, "sleep", new=_record_sleep(slept)):
|
||||
_parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, [throttled, done])
|
||||
|
||||
assert len(requests) == 2
|
||||
assert sum(slept) == 10.0, "the loop's own gap, not the sliver it was offered"
|
||||
|
||||
def test_a_slow_poll_spends_the_budget_it_actually_took(self, monkeypatch):
|
||||
# Counting only the waits would let a gateway that answers slowly run
|
||||
# the call far past its budget, leaving the backstop to do the work the
|
||||
# budget is supposed to do. A look costs what it takes.
|
||||
monkeypatch.setattr(flux3, "_POLL_GAP_SECONDS", 1.0)
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 30.0)
|
||||
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
|
||||
|
||||
monkeypatch.setattr(flux3, "time", SimpleNamespace(monotonic=_stepped_clock(20.0)))
|
||||
|
||||
_parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, running)
|
||||
|
||||
assert len(requests) == 2, "two twenty-second looks exhaust a thirty-second budget"
|
||||
|
||||
def test_a_poll_outwaits_the_gateways_own_poll_budget(self):
|
||||
# The gateway bounds one status read at 45s across its retries and
|
||||
# regional redirect hops. Giving up before it does turns a slow but
|
||||
# healthy poll into a transport error, and an error ends the loop.
|
||||
assert flux3._POLL_READ_TIMEOUT_SECONDS > 45.0
|
||||
|
||||
def test_a_blip_costs_a_look_rather_than_the_rest_of_the_call(self, monkeypatch):
|
||||
# The generation runs upstream and is unaffected by our failing to ask
|
||||
# about it, so one unreachable moment must not throw away the minutes
|
||||
# of budget left. Returning it would end the call on an error the model
|
||||
# can only answer by polling again at once — the burst the loop exists
|
||||
# to prevent.
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 100.0)
|
||||
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {}, "guidance": "Done."})
|
||||
|
||||
parsed, requests = _call(
|
||||
flux3._handle_get_result,
|
||||
{"id": "bfl_job_1"},
|
||||
[RuntimeError("connection reset"), done],
|
||||
)
|
||||
|
||||
assert len(requests) == 2, "the loop looked again after the blip"
|
||||
assert parsed["result"] == "Done."
|
||||
|
||||
def test_a_gateway_answering_in_html_counts_as_unreachable(self, monkeypatch):
|
||||
# What a 502 from an edge in front of the gateway looks like from here:
|
||||
# a status code and a page, with no error the model could act on. That
|
||||
# is an absent answer, not a refusal, so it is retried like one.
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 100.0)
|
||||
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {}, "guidance": "Done."})
|
||||
|
||||
_parsed, requests = _call(
|
||||
flux3._handle_get_result,
|
||||
{"id": "bfl_job_1"},
|
||||
[_FakeResponse(502, None, text="<html>bad gateway</html>"), done],
|
||||
)
|
||||
|
||||
assert len(requests) == 2
|
||||
|
||||
def test_a_gateway_that_stays_down_is_reported_rather_than_retried_out(self, monkeypatch):
|
||||
# Tolerance is for blips. A gateway that is genuinely down has to reach
|
||||
# the model promptly, not after minutes of a budget spent on a host
|
||||
# that is not answering.
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 1000.0)
|
||||
|
||||
parsed, requests = _call(
|
||||
flux3._handle_get_result,
|
||||
{"id": "bfl_job_1"},
|
||||
RuntimeError("connection reset"),
|
||||
)
|
||||
|
||||
assert len(requests) == flux3._MAX_CONSECUTIVE_TRANSPORT_ERRORS
|
||||
assert "Could not reach" in parsed["error"]
|
||||
|
||||
def test_the_tolerance_counts_consecutive_failures_only(self, monkeypatch):
|
||||
# A flaky gateway that answers every other look is still usable, so the
|
||||
# count has to reset on an answer rather than accumulate over the call.
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 1000.0)
|
||||
blip = RuntimeError("connection reset")
|
||||
running = _FakeResponse(200, {"id": "bfl_job_1", "status": "Generating", "guidance": "Still going."})
|
||||
done = _FakeResponse(200, {"id": "bfl_job_1", "status": "Ready", "result": {}, "guidance": "Done."})
|
||||
|
||||
parsed, requests = _call(
|
||||
flux3._handle_get_result,
|
||||
{"id": "bfl_job_1"},
|
||||
[blip, blip, running, blip, blip, done],
|
||||
)
|
||||
|
||||
assert len(requests) == 6, "four blips, never three in a row, so the call survives"
|
||||
assert parsed["result"] == "Done."
|
||||
|
||||
def test_a_throttle_longer_than_the_budget_is_handed_back(self, monkeypatch):
|
||||
# A five-minute generation cooldown cannot be absorbed inside one call,
|
||||
# so the model gets the message and the number rather than a call that
|
||||
# sits out a wait it can never finish.
|
||||
monkeypatch.setattr(flux3, "_POLL_BUDGET_SECONDS", 100.0)
|
||||
response = _FakeResponse(
|
||||
429,
|
||||
{"error": {"message": "Wait 210 seconds.", "details": {"retryAfterSeconds": 210}}},
|
||||
)
|
||||
|
||||
parsed, requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, response)
|
||||
|
||||
assert len(requests) == 1
|
||||
assert parsed["error"] == "Wait 210 seconds."
|
||||
|
||||
def test_the_backstop_answers_rather_than_letting_the_bridge_kill_the_call(self, monkeypatch):
|
||||
# model_tools' async bridge abandons a tool at 300s and reports it as a
|
||||
# bare "TimeoutError:" — no job id, nothing to say the generation is
|
||||
# still alive and one poll away. Whatever stalls inside, the model is
|
||||
# answered from here first.
|
||||
monkeypatch.setattr(flux3, "_CALL_BACKSTOP_SECONDS", 0.01)
|
||||
|
||||
async def _never_finishes(*_args, **_kwargs):
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(flux3, "_poll_until_done", _never_finishes)
|
||||
|
||||
parsed, _requests = _call(flux3._handle_get_result, {"id": "bfl_job_1"}, _FakeResponse(200, {}))
|
||||
|
||||
assert parsed["details"] == {"id": "bfl_job_1", "status": "Generating"}
|
||||
assert "bfl_flux3_get_result" in parsed["result"]
|
||||
assert "bfl_job_1" in parsed["result"]
|
||||
|
||||
def test_a_poll_does_not_inherit_the_submit_read_timeout(self):
|
||||
# A status GET answers at once. Left on the submit path's patience, one
|
||||
# hung poll would spend the whole call's budget by itself — while submit,
|
||||
# which really does sit behind an upload and an upstream call, keeps it.
|
||||
import httpx
|
||||
|
||||
timeouts = []
|
||||
sink = []
|
||||
settled = _FakeResponse(200, {"id": "j", "status": "Error", "guidance": "over"})
|
||||
|
||||
def _client(**kwargs):
|
||||
timeouts.append(kwargs.get("timeout"))
|
||||
return _FakeClient(settled, sink)
|
||||
|
||||
with patch.object(flux3, "managed_gateway_auth_headers", return_value={"Authorization": "Bearer t"}), \
|
||||
patch.object(httpx, "AsyncClient", _client):
|
||||
_run(flux3._handle_get_result({"id": "j"}))
|
||||
_run(flux3._handle_text_to_video({"prompt": "a"}))
|
||||
|
||||
poll_timeout, submit_timeout = timeouts
|
||||
assert poll_timeout.read == flux3._POLL_READ_TIMEOUT_SECONDS
|
||||
assert submit_timeout.read == flux3._TRANSPORT_READ_TIMEOUT_SECONDS
|
||||
assert poll_timeout.read < submit_timeout.read
|
||||
|
||||
def test_the_pacing_stays_clear_of_the_agents_per_tool_ceiling(self):
|
||||
# The whole point of the two bounds: a clip finishing on the last look
|
||||
# still has to be downloaded inside the backstop, and the backstop has
|
||||
# to answer before model_tools' async bridge abandons the tool at 300s.
|
||||
assert _DEFAULT_POLL_BUDGET_SECONDS < _DEFAULT_CALL_BACKSTOP_SECONDS
|
||||
assert _DEFAULT_CALL_BACKSTOP_SECONDS < 300.0
|
||||
|
||||
def test_download_timeout_never_outlives_the_backstop(self):
|
||||
# Near the end of the call, remaining budget after grace is a few
|
||||
# seconds. Clamping that up used to schedule a download the outer
|
||||
# wait_for then cancelled, answering "still generating" for a Ready job.
|
||||
started = time.monotonic() - (
|
||||
flux3._CALL_BACKSTOP_SECONDS - flux3._DOWNLOAD_GRACE_SECONDS - 2.0
|
||||
)
|
||||
assert flux3._download_read_timeout(started) <= 2.0 + 0.5 # clock noise only
|
||||
|
||||
def test_ready_saves_the_clip_and_never_returns_the_signed_url(self, tmp_path):
|
||||
# The signed URL is a bearer credential for the clip and it used to be
|
||||
# re-keyed into a shell command by hand, dropping characters. Neither
|
||||
|
|
|
|||
|
|
@ -110,12 +110,11 @@ def test_managed_vendor_endpoints_pin_the_deployed_gateway_url():
|
|||
typo'd pseudo-vendor to a non-existent host while every other test stubbed
|
||||
it): default builder, real deployed host, pinned vendor path.
|
||||
"""
|
||||
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=True), \
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
|
||||
clear=False,
|
||||
):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"TOOL_GATEWAY_DOMAIN": "nousresearch.com", "TOOL_GATEWAY_SCHEME": "https"},
|
||||
clear=False,
|
||||
):
|
||||
os.environ.pop("TOOL_GATEWAY_URL", None)
|
||||
endpoints = managed_tool_gateway.managed_vendor_endpoints("bfl")
|
||||
|
||||
|
|
@ -126,8 +125,31 @@ def test_managed_vendor_endpoints_pin_the_deployed_gateway_url():
|
|||
}
|
||||
|
||||
|
||||
def test_managed_vendor_endpoints_unreachable_when_managed_tools_disabled():
|
||||
with patch.object(managed_tool_gateway, "managed_nous_tools_enabled", return_value=False):
|
||||
def test_managed_vendor_endpoints_do_not_consult_entitlement():
|
||||
"""Address resolution, not a policy decision.
|
||||
|
||||
What an account may spend is the gateway's ruling, stated in its refusals.
|
||||
Guessing at it here would hide the address from a caller the server would
|
||||
have served, so entitlement must not be read on this path at all.
|
||||
"""
|
||||
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False), \
|
||||
patch.object(
|
||||
managed_tool_gateway,
|
||||
"managed_nous_tools_enabled",
|
||||
side_effect=AssertionError("entitlement must not gate address resolution"),
|
||||
):
|
||||
os.environ.pop("TOOL_GATEWAY_URL", None)
|
||||
endpoints = managed_tool_gateway.managed_vendor_endpoints("bfl")
|
||||
|
||||
assert endpoints is not None
|
||||
assert endpoints["base_url"] == "https://tool-gateway.nousresearch.com/api/bfl"
|
||||
|
||||
|
||||
def test_managed_vendor_endpoints_are_none_when_no_origin_resolves():
|
||||
# A misconfigured scheme leaves nothing to call, and the caller reports
|
||||
# that rather than building a URL out of a broken setting.
|
||||
with patch.dict(os.environ, {"TOOL_GATEWAY_SCHEME": "ftp"}, clear=False):
|
||||
os.environ.pop("TOOL_GATEWAY_URL", None)
|
||||
assert managed_tool_gateway.managed_vendor_endpoints("bfl") is None
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
"""Native BFL FLUX 3 video generation tools, backed by the Nous tool gateway.
|
||||
|
||||
These are service-gated native tools in the ``image_generate`` mold: schemas
|
||||
and descriptions are pinned here as build-time facts, the handlers speak the
|
||||
gateway's own REST contract, and ``check_fn`` hides the whole toolset unless
|
||||
the user is signed in to Nous Portal with paid service access. No runtime
|
||||
discovery, and no server-supplied schema is ever consulted — that is the point
|
||||
of the design.
|
||||
These are native tools in the ``image_generate`` mold: schemas and
|
||||
descriptions are pinned here as build-time facts, the handlers speak the
|
||||
gateway's own REST contract, and ``check_fn`` hides the whole toolset only when
|
||||
there is no Nous sign-in to call it with — never on entitlement, which is the
|
||||
gateway's to rule on. No runtime discovery, and no server-supplied schema is
|
||||
ever consulted — that is the point of the design.
|
||||
|
||||
The wire is two calls against the gateway's managed mount, and it names the
|
||||
vendor but not the vendor's API:
|
||||
|
|
@ -32,6 +32,7 @@ import asyncio
|
|||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from tools.registry import registry
|
||||
|
|
@ -39,6 +40,7 @@ from tools.managed_tool_gateway import (
|
|||
build_managed_media_uploader,
|
||||
managed_gateway_auth_headers,
|
||||
managed_vendor_endpoints,
|
||||
peek_nous_access_token,
|
||||
read_nous_access_token,
|
||||
)
|
||||
|
||||
|
|
@ -48,13 +50,14 @@ _TOOLSET = "bfl"
|
|||
_VENDOR = "bfl"
|
||||
|
||||
# Submit sits behind the gateway's upstream call plus upload-reference
|
||||
# resolution, and the gateway bounds a poll server-side. One generous read
|
||||
# timeout covers both without ever approaching the agent's per-tool budget.
|
||||
# resolution, so it is given a generous read timeout. A poll passes its own,
|
||||
# much shorter one (see _POLL_READ_TIMEOUT_SECONDS): the job endpoint answers
|
||||
# at once, and a poll allowed to hang this long would spend the whole call.
|
||||
_TRANSPORT_READ_TIMEOUT_SECONDS = 180.0
|
||||
_TRANSPORT_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
|
||||
_SIGN_IN_MESSAGE = (
|
||||
"BFL video generation needs a Nous Portal sign-in with an active paid plan. "
|
||||
"BFL video generation needs a Nous Portal sign-in. "
|
||||
"Ask the user to run `hermes model` and sign in to Nous, then retry."
|
||||
)
|
||||
|
||||
|
|
@ -109,7 +112,12 @@ def _endpoints() -> Optional[dict]:
|
|||
return managed_vendor_endpoints(_VENDOR)
|
||||
|
||||
|
||||
async def _call_gateway(method: str, url: str, json_body: Optional[dict] = None) -> str:
|
||||
async def _call_gateway(
|
||||
method: str,
|
||||
url: str,
|
||||
json_body: Optional[dict] = None,
|
||||
read_timeout: Optional[float] = None,
|
||||
) -> str:
|
||||
"""One REST round trip, rendered as this tool's result.
|
||||
|
||||
The gateway's ``guidance`` (on success) and ``error.message`` (on a
|
||||
|
|
@ -117,6 +125,11 @@ async def _call_gateway(method: str, url: str, json_body: Optional[dict] = None)
|
|||
verbatim. A refusal is a normal outcome the model can respond to — being
|
||||
throttled is not a broken tool — so only genuinely unreadable responses
|
||||
become ``error``.
|
||||
|
||||
Those unreadable ones carry ``transport_error`` as well. A refusal is the
|
||||
gateway's ruling on the request; a transport failure is the absence of one,
|
||||
and says nothing about the job. The poll loop tells them apart on that key
|
||||
rather than on the wording of a message.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
|
|
@ -125,13 +138,17 @@ async def _call_gateway(method: str, url: str, json_body: Optional[dict] = None)
|
|||
return json.dumps({"error": _SIGN_IN_MESSAGE})
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
timeout = httpx.Timeout(_TRANSPORT_CONNECT_TIMEOUT_SECONDS, read=_TRANSPORT_READ_TIMEOUT_SECONDS)
|
||||
timeout = httpx.Timeout(
|
||||
_TRANSPORT_CONNECT_TIMEOUT_SECONDS,
|
||||
read=_TRANSPORT_READ_TIMEOUT_SECONDS if read_timeout is None else read_timeout,
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.request(method, url, headers=headers, json=json_body)
|
||||
except Exception as exc:
|
||||
return json.dumps({
|
||||
"error": f"Could not reach the video-generation gateway: {type(exc).__name__}: {exc}",
|
||||
"transport_error": True,
|
||||
})
|
||||
|
||||
if response.status_code == 401:
|
||||
|
|
@ -143,8 +160,11 @@ async def _call_gateway(method: str, url: str, json_body: Optional[dict] = None)
|
|||
payload = None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
# An edge or a proxy answering in HTML rather than the gateway itself,
|
||||
# which is what a 502 or 504 in front of it looks like from here.
|
||||
return json.dumps({
|
||||
"error": f"The video-generation gateway answered HTTP {response.status_code} with an unreadable body.",
|
||||
"transport_error": True,
|
||||
})
|
||||
|
||||
if response.status_code >= 400:
|
||||
|
|
@ -179,17 +199,50 @@ _MEDIA_FIELDS = {
|
|||
_MAX_IMAGES = 10
|
||||
|
||||
|
||||
# How long one get_result call waits before its second look. The bound that
|
||||
# matters is the whole call rather than this number: model_tools' async bridge
|
||||
# abandons a tool at 300s, and one call spends two polls (each bounded
|
||||
# server-side at 45s), this wait, and on Ready the download of the clip. There
|
||||
# is room to roughly double this; the reason not to is that a finished job is
|
||||
# only noticed at the next look, so the wait is also the notice delay.
|
||||
_POLL_FOLLOW_UP_WAIT_SECONDS = 45.0
|
||||
# Taken in slices so the wait is answerable. Nothing outside a tool can end a
|
||||
# call that has already started — the executor only checks for an interrupt
|
||||
# between tools — so a tool that blocks this long has to watch the flag itself.
|
||||
# One get_result call looks repeatedly rather than a fixed twice. The job
|
||||
# endpoint answers immediately — there is no long poll — so every second
|
||||
# between looks is a second a finished clip goes unnoticed, and many
|
||||
# short-spaced looks per call cut both that notice delay and the number of
|
||||
# times the model has to decide to keep waiting.
|
||||
#
|
||||
# Two bounds keep the loop inside the agent's per-tool ceiling. model_tools'
|
||||
# async bridge abandons a tool at 300s and reports it to the model as a bare
|
||||
# "TimeoutError:" — no job id, no sign the generation is still alive — which is
|
||||
# the worst answer this tool can give, so neither bound may approach it.
|
||||
# _CALL_BACKSTOP_SECONDS is the wall-clock guarantee over the whole handler;
|
||||
# _POLL_BUDGET_SECONDS stops new looks earlier still, and the difference
|
||||
# between them is what a clip finishing on the last look has to download in.
|
||||
_CALL_BACKSTOP_SECONDS = 240.0
|
||||
_POLL_BUDGET_SECONDS = 180.0
|
||||
# The gap between looks, and so the notice delay on a finished job. The
|
||||
# gateway's poll limiter allows 120 a minute per principal, and it only ever
|
||||
# has one generation of ours to answer for, so this cadence spends about a
|
||||
# tenth of what it permits.
|
||||
_POLL_GAP_SECONDS = 5.0
|
||||
# The budget is counted as it is spent — the waits and the time each look
|
||||
# actually takes — rather than read off a wall clock. A slow gateway therefore
|
||||
# costs looks instead of overrunning the call, and the loop stays testable
|
||||
# without a fake clock.
|
||||
#
|
||||
# Waits are taken in slices so they stay answerable. Nothing outside a tool can
|
||||
# end a call that has already started — the executor only checks for an
|
||||
# interrupt between tools — so a tool that blocks this long watches the flag
|
||||
# itself.
|
||||
_POLL_WAIT_SLICE_SECONDS = 1.0
|
||||
# A poll's own read timeout. It has to clear the gateway's server-side poll
|
||||
# budget, which bounds one status read at 45s across its retries and its
|
||||
# regional redirect hops: cutting a poll off before the server would give up
|
||||
# turns a slow-but-healthy read into a transport error, and an error ends the
|
||||
# loop. Still far below the submit path's patience, so a wedged poll cannot
|
||||
# quietly spend the whole call either.
|
||||
_POLL_READ_TIMEOUT_SECONDS = 60.0
|
||||
# How many looks in a row may fail to reach the gateway before the loop gives
|
||||
# up on the call. A blip costs a look rather than the whole remaining budget:
|
||||
# ending on the first one hands the model an error it can only answer by
|
||||
# polling again immediately, which is the burst this loop exists to prevent.
|
||||
# Bounded so a gateway that is genuinely down is reported promptly instead of
|
||||
# being retried for minutes.
|
||||
_MAX_CONSECUTIVE_TRANSPORT_ERRORS = 3
|
||||
|
||||
# Mirrors the gateway's BFL statuses
|
||||
_TERMINAL_POLL_STATUSES = frozenset(
|
||||
|
|
@ -204,24 +257,57 @@ def _poll_is_finished(raw: str) -> bool:
|
|||
except Exception:
|
||||
return True
|
||||
if not isinstance(payload, dict) or "error" in payload:
|
||||
# A refusal carries its own guidance (a wait, a limit, a dead job).
|
||||
# Sleeping on it would only delay showing the model what to do.
|
||||
# A refusal carries its own guidance (a limit, a dead job, a bad id),
|
||||
# and sleeping on it would only delay showing the model what to do.
|
||||
# The one exception is a throttle, which states a wait the loop can
|
||||
# absorb — the caller checks _retry_after_seconds before asking here.
|
||||
return True
|
||||
details = payload.get("details")
|
||||
status = details.get("status") if isinstance(details, dict) else None
|
||||
return not isinstance(status, str) or status in _TERMINAL_POLL_STATUSES
|
||||
|
||||
|
||||
async def _wait_before_second_look() -> bool:
|
||||
"""Hold the call open between looks; False if the user interrupted.
|
||||
def _retry_after_seconds(raw: str) -> Optional[float]:
|
||||
"""How long the gateway asked us to wait, when a refusal is a throttle.
|
||||
|
||||
Counted down rather than clock-driven: this paces polling, so a slice
|
||||
that runs long changes nothing, and the loop stays testable without a
|
||||
fake clock.
|
||||
A throttle is the one refusal worth absorbing here rather than handing
|
||||
back. Returning it ends the call, and the model it lands on has no clock —
|
||||
told to wait, it asks again immediately — so a rate limit answered that way
|
||||
produces a tighter loop than the one that tripped it. The gateway sends the
|
||||
wait as a number alongside the message, so there is nothing to parse out of
|
||||
prose.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
return None
|
||||
if not isinstance(payload, dict) or "error" not in payload:
|
||||
return None
|
||||
details = payload.get("details")
|
||||
value = details.get("retryAfterSeconds") if isinstance(details, dict) else None
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0:
|
||||
return None
|
||||
return float(value)
|
||||
|
||||
|
||||
def _is_transport_error(raw: str) -> bool:
|
||||
"""True when the gateway did not answer, as opposed to answering "no".
|
||||
|
||||
Set by ``_call_gateway`` on the paths where nothing readable came back, so
|
||||
this reads a flag rather than matching on the text of a message.
|
||||
"""
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except Exception:
|
||||
return False
|
||||
return isinstance(payload, dict) and payload.get("transport_error") is True
|
||||
|
||||
|
||||
async def _wait_between_looks(seconds: float) -> bool:
|
||||
"""Hold the call open until the next look; False if the user interrupted."""
|
||||
from tools.interrupt import is_interrupted
|
||||
|
||||
remaining = _POLL_FOLLOW_UP_WAIT_SECONDS
|
||||
remaining = seconds
|
||||
while remaining > 0:
|
||||
if is_interrupted():
|
||||
return False
|
||||
|
|
@ -319,9 +405,14 @@ async def _deliver_media(value, permitted: tuple, task_id: Optional[str]):
|
|||
# Saving the finished clip
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Generous: a 50MB clip over a slow link, still well inside the agent's budget.
|
||||
# Generous: a 50MB clip over a slow link. Bounded per call by what is left of
|
||||
# the backstop, so this is a ceiling rather than the figure actually used.
|
||||
_DOWNLOAD_READ_TIMEOUT_SECONDS = 300.0
|
||||
_DOWNLOAD_CONNECT_TIMEOUT_SECONDS = 15.0
|
||||
# Kept clear of the backstop so the download's own timeout fires first: that
|
||||
# way a stalled save is reported as one, instead of being cancelled mid-write
|
||||
# with the call's answer lost.
|
||||
_DOWNLOAD_GRACE_SECONDS = 5.0
|
||||
# A rejection page is a few hundred bytes of XML; a clip is megabytes. Anything
|
||||
# smaller than this is not the video, whatever the HTTP status said.
|
||||
_MIN_PLAUSIBLE_VIDEO_BYTES = 64 * 1024
|
||||
|
|
@ -330,7 +421,24 @@ _MIN_PLAUSIBLE_VIDEO_BYTES = 64 * 1024
|
|||
_MAX_FILENAME_ATTEMPTS = 50
|
||||
|
||||
|
||||
async def _save_if_ready(raw: str, save_to) -> str:
|
||||
def _download_read_timeout(started: float) -> float:
|
||||
"""What is left of the call for a download, never more than the ceiling.
|
||||
|
||||
Without this the download's own generous timeout outlives the agent's
|
||||
per-tool ceiling, and the "saving failed, poll again to retry" answer below
|
||||
is never reached: the bridge kills the call first and the model is told
|
||||
only "TimeoutError", with no indication the clip exists and is one poll
|
||||
away.
|
||||
|
||||
Must not invent time past what remains: clamping upward used to schedule a
|
||||
download the outer ``asyncio.wait_for`` then cancelled, answering with a
|
||||
false ``_still_generating`` while the job was already Ready.
|
||||
"""
|
||||
left = _CALL_BACKSTOP_SECONDS - (time.monotonic() - started) - _DOWNLOAD_GRACE_SECONDS
|
||||
return max(0.0, min(_DOWNLOAD_READ_TIMEOUT_SECONDS, left))
|
||||
|
||||
|
||||
async def _save_if_ready(raw: str, save_to, started: float) -> str:
|
||||
"""Download a finished clip and swap the signed URL for a local path.
|
||||
|
||||
The URL is handled here rather than by the model on purpose. It is long and
|
||||
|
|
@ -367,7 +475,7 @@ async def _save_if_ready(raw: str, save_to) -> str:
|
|||
result.pop("sample", None)
|
||||
|
||||
try:
|
||||
target, size = await _download_video(url.strip(), save_to)
|
||||
target, size = await _download_video(url.strip(), save_to, started)
|
||||
except Exception as exc:
|
||||
payload["result"] = (
|
||||
f"The clip finished but saving it failed: {type(exc).__name__}: {exc}. "
|
||||
|
|
@ -400,7 +508,7 @@ def _delivery_lead_in(target) -> str:
|
|||
return f"Saved to {target}. "
|
||||
|
||||
|
||||
async def _download_video(url: str, save_to) -> tuple:
|
||||
async def _download_video(url: str, save_to, started: float) -> tuple:
|
||||
"""Stream the clip to disk, returning (path, bytes).
|
||||
|
||||
SSRF-guarded, for the same reason the upload PUT is: this URL comes from
|
||||
|
|
@ -416,7 +524,7 @@ async def _download_video(url: str, save_to) -> tuple:
|
|||
# plausible, so a failed download can never leave something that looks like
|
||||
# a playable file behind.
|
||||
partial = target.with_name(target.name + ".part")
|
||||
timeout = httpx.Timeout(_DOWNLOAD_CONNECT_TIMEOUT_SECONDS, read=_DOWNLOAD_READ_TIMEOUT_SECONDS)
|
||||
timeout = httpx.Timeout(_DOWNLOAD_CONNECT_TIMEOUT_SECONDS, read=_download_read_timeout(started))
|
||||
|
||||
try:
|
||||
async with create_ssrf_safe_async_client(timeout=timeout, follow_redirects=True) as client:
|
||||
|
|
@ -573,6 +681,65 @@ async def _handle_video_continuation(args: dict, **kwargs) -> str:
|
|||
return await _submit("video_continuation", prepared)
|
||||
|
||||
|
||||
def _still_generating(job_id: str) -> str:
|
||||
"""The backstop's answer: an ordinary "call again", never a raised timeout."""
|
||||
return json.dumps(
|
||||
{
|
||||
"result": (
|
||||
"Still generating. This call reached its own time limit, which the job is "
|
||||
f"unaffected by — call bfl_flux3_get_result again with id={job_id} to keep "
|
||||
"waiting."
|
||||
),
|
||||
"details": {"id": job_id, "status": "Generating"},
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
|
||||
async def _poll_until_done(url: str, save_to, started: float) -> str:
|
||||
"""Look until the job settles, the budget runs out, or the user stops.
|
||||
|
||||
The waiting is absorbed here rather than asked of the model. A model has no
|
||||
clock: told to wait it emits "I'll check back in a minute" and its next
|
||||
action lands immediately, so guidance produced a burst of polls rather than
|
||||
a paced one. Waiting inside the call cannot be skipped, needs no shell, and
|
||||
works the same on every platform.
|
||||
"""
|
||||
spent = 0.0
|
||||
unanswered = 0
|
||||
while True:
|
||||
look_started = time.monotonic()
|
||||
raw = await _call_gateway("GET", url, read_timeout=_POLL_READ_TIMEOUT_SECONDS)
|
||||
spent += time.monotonic() - look_started
|
||||
|
||||
if _is_transport_error(raw):
|
||||
# The job is upstream and unaffected by our failure to ask about
|
||||
# it, so a blip costs this look and the loop tries again.
|
||||
unanswered += 1
|
||||
if unanswered >= _MAX_CONSECUTIVE_TRANSPORT_ERRORS:
|
||||
return raw
|
||||
gap = _POLL_GAP_SECONDS
|
||||
else:
|
||||
unanswered = 0
|
||||
throttled_for = _retry_after_seconds(raw)
|
||||
if throttled_for is None and _poll_is_finished(raw):
|
||||
return await _save_if_ready(raw, save_to, started)
|
||||
# Never faster than our own cadence, however short a wait the
|
||||
# gateway names: its number is a floor on politeness, not a licence
|
||||
# to hammer.
|
||||
gap = _POLL_GAP_SECONDS if throttled_for is None else max(throttled_for, _POLL_GAP_SECONDS)
|
||||
|
||||
if gap <= 0 or spent + gap >= _POLL_BUDGET_SECONDS:
|
||||
# Out of budget. A still-generating status carries the gateway's own
|
||||
# "call again"; a throttle we could not outwait carries its wait.
|
||||
return raw
|
||||
if not await _wait_between_looks(gap):
|
||||
# Interrupted mid-wait: hand back the status we already have rather
|
||||
# than spending a round trip the user has just asked us to stop for.
|
||||
return raw
|
||||
spent += gap
|
||||
|
||||
|
||||
async def _handle_get_result(args: dict, **kwargs) -> str:
|
||||
job_id = (args or {}).get("id")
|
||||
if not isinstance(job_id, str) or not job_id.strip():
|
||||
|
|
@ -582,25 +749,22 @@ async def _handle_get_result(args: dict, **kwargs) -> str:
|
|||
return _error("BFL video generation is not available in this build.")
|
||||
from urllib.parse import quote
|
||||
|
||||
url = f"{endpoints['base_url']}/generations/{quote(job_id.strip(), safe='')}"
|
||||
job_id = job_id.strip()
|
||||
url = f"{endpoints['base_url']}/generations/{quote(job_id, safe='')}"
|
||||
save_to = (args or {}).get("save_to")
|
||||
started = time.monotonic()
|
||||
|
||||
raw = await _call_gateway("GET", url)
|
||||
if _poll_is_finished(raw):
|
||||
return await _save_if_ready(raw, save_to)
|
||||
|
||||
# Still running, so absorb the wait here instead of asking the model to
|
||||
# take it. A model has no clock: told to wait it emits "I'll wait a minute"
|
||||
# and its next action lands immediately, so the guidance produced a burst of
|
||||
# polls rather than a paced one. Waiting inside the call cannot be skipped,
|
||||
# needs no shell, and works the same on every platform. One call therefore
|
||||
# covers a couple of minutes and returns as soon as a look finds it done.
|
||||
if not await _wait_before_second_look():
|
||||
# Interrupted mid-wait: hand back the status we already have rather
|
||||
# than spending a round trip the user has just asked us to stop for.
|
||||
return await _save_if_ready(raw, save_to)
|
||||
raw = await _call_gateway("GET", url)
|
||||
return await _save_if_ready(raw, save_to)
|
||||
# The loop stops itself once its budget is spent, but a look already in
|
||||
# flight still runs to completion, and a download follows it. This is the
|
||||
# wall-clock guarantee over all of that: whatever stalls inside, the model
|
||||
# is answered from here rather than by the async bridge, whose own timeout
|
||||
# arrives as a bare "TimeoutError:".
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
_poll_until_done(url, save_to, started), timeout=_CALL_BACKSTOP_SECONDS
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
return _still_generating(job_id)
|
||||
|
||||
|
||||
async def _handle_prompting_guide(args: dict, **kwargs) -> str:
|
||||
|
|
@ -611,14 +775,52 @@ async def _handle_prompting_guide(args: dict, **kwargs) -> str:
|
|||
# Gating
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_nous_credential() -> bool:
|
||||
"""True when a Nous bearer is on hand, without spending a refresh to learn it.
|
||||
|
||||
Two lookups, because the transport itself has two.
|
||||
``peek_nous_access_token`` covers the env override and the active store's
|
||||
cached token. A profile that was never logged into separately has neither,
|
||||
and reads the credential from the global-root ``auth.json`` — the same
|
||||
fallback ``resolve_nous_access_token`` takes when the transport refreshes.
|
||||
Probing only the first would hide the tools from a profile whose calls
|
||||
would have gone through perfectly well.
|
||||
|
||||
Neither lookup validates or refreshes the token: an expired credential is
|
||||
the gateway's 401 to report, and that answer already asks for a sign-in.
|
||||
"""
|
||||
if peek_nous_access_token():
|
||||
return True
|
||||
try:
|
||||
from hermes_cli.auth import get_provider_auth_state
|
||||
|
||||
state = get_provider_auth_state("nous") or {}
|
||||
except Exception:
|
||||
return False
|
||||
token = state.get("access_token")
|
||||
return isinstance(token, str) and bool(token.strip())
|
||||
|
||||
|
||||
def check_bfl_requirements() -> bool:
|
||||
"""Visible to anyone signed in to Nous; the gateway rules on the rest.
|
||||
|
||||
No entitlement check. What an account may generate — plan, credits, per
|
||||
account limits — is the gateway's decision, and it refuses with a reason
|
||||
written for the model to act on; deciding it a second time here can only
|
||||
disagree with the server and hide the tools from someone entitled to them.
|
||||
|
||||
A sign-in is still required, because the gateway takes a Nous bearer and
|
||||
nothing else: with no credential every call could only ever answer "sign
|
||||
in", so the six schemas would be pure cost on every API call.
|
||||
|
||||
Stays a pair of file reads — no portal probe, no OAuth refresh. Behind the
|
||||
registry's 30s cache this still runs on every CLI start, gateway session
|
||||
and cron tick.
|
||||
"""
|
||||
try:
|
||||
if _endpoints() is None:
|
||||
return False
|
||||
from hermes_cli.nous_account import get_nous_portal_account_info
|
||||
|
||||
info = get_nous_portal_account_info()
|
||||
return bool(getattr(info, "logged_in", False) and getattr(info, "paid_service_access", False))
|
||||
return _has_nous_credential()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
|
@ -811,7 +1013,7 @@ GET_RESULT_SCHEMA = {
|
|||
"description": (
|
||||
"Poll a FLUX 3 video job by the job id a generate tool returned. Generation takes minutes "
|
||||
"and a long Generating phase is normal. This call waits for you while the job runs, so it "
|
||||
"may take a couple of minutes; if it returns still generating, just call it again. Do not "
|
||||
"may run for several minutes; if it returns still generating, just call it again. Do not "
|
||||
"sleep between calls. "
|
||||
"On Ready the clip is downloaded for you and the response gives its local path; your only "
|
||||
"remaining step is to deliver that file as the response describes."
|
||||
|
|
@ -945,8 +1147,8 @@ Generating phase is normal, not a stall. Nothing reaches disk before the job is
|
|||
Ready, so checking folders mid-run tells you nothing.
|
||||
|
||||
The waiting is not yours to do. bfl_flux3_get_result takes the pause itself
|
||||
while a job is still running, so one call can occupy a couple of minutes and
|
||||
comes back the moment the job finishes. If it returns still generating, just
|
||||
while a job is still running, so one call can occupy several minutes and comes
|
||||
back within seconds of the job finishing. If it returns still generating, just
|
||||
call it again — no sleeping, no interval to judge, nothing to time.
|
||||
|
||||
A job survives client restarts: re-poll the same id rather than resubmitting,
|
||||
|
|
|
|||
|
|
@ -227,17 +227,25 @@ def managed_vendor_endpoints(
|
|||
vendor: str,
|
||||
gateway_builder: Optional[Callable[[str], str]] = None,
|
||||
) -> Optional[dict]:
|
||||
"""Absolute URLs for a managed vendor, or ``None`` when unreachable.
|
||||
"""Absolute URLs for a managed vendor, or ``None`` when none resolves.
|
||||
|
||||
``None`` means managed Nous tools are disabled for this build, which is
|
||||
what keeps a user who could never use the vendor from being shown its
|
||||
tools.
|
||||
Address resolution only: entitlement is deliberately not consulted here.
|
||||
What an account may spend on a managed vendor is the gateway's own
|
||||
decision, stated in its refusals, and re-deciding it on the client can only
|
||||
ever disagree with the server. A caller that wants to hide its tools from
|
||||
users who could not call them at all does that in its ``check_fn``.
|
||||
|
||||
``None`` means no origin could be resolved — a misconfigured
|
||||
``TOOL_GATEWAY_SCHEME`` — so there is nothing to call.
|
||||
"""
|
||||
if not managed_nous_tools_enabled():
|
||||
builder = gateway_builder or build_vendor_gateway_url
|
||||
try:
|
||||
origin = builder(_MANAGED_GATEWAY_VENDOR).rstrip("/")
|
||||
except ValueError:
|
||||
return None
|
||||
if not origin:
|
||||
return None
|
||||
|
||||
builder = gateway_builder or build_vendor_gateway_url
|
||||
origin = builder(_MANAGED_GATEWAY_VENDOR).rstrip("/")
|
||||
return {
|
||||
"origin": origin,
|
||||
"base_url": f"{origin}{managed_vendor_base_path(vendor)}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue