diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index acd22d88485..96dce03fae9 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -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( diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 162a6427643..dab26dbd914 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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"}) diff --git a/tests/agent/test_auxiliary_main_first.py b/tests/agent/test_auxiliary_main_first.py index c7314f7868a..6b19337d0fd 100644 --- a/tests/agent/test_auxiliary_main_first.py +++ b/tests/agent/test_auxiliary_main_first.py @@ -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") diff --git a/tests/tools/test_flux3_video_tool.py b/tests/tools/test_flux3_video_tool.py index 8092c8976f1..35323842a58 100644 --- a/tests/tools/test_flux3_video_tool.py +++ b/tests/tools/test_flux3_video_tool.py @@ -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="bad gateway"), 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 diff --git a/tests/tools/test_managed_tool_gateway.py b/tests/tools/test_managed_tool_gateway.py index 1c90bcb90f3..3e281b0c696 100644 --- a/tests/tools/test_managed_tool_gateway.py +++ b/tests/tools/test_managed_tool_gateway.py @@ -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 diff --git a/tools/flux3_video_tool.py b/tools/flux3_video_tool.py index e80f766746f..8f7ae6754fb 100644 --- a/tools/flux3_video_tool.py +++ b/tools/flux3_video_tool.py @@ -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, diff --git a/tools/managed_tool_gateway.py b/tools/managed_tool_gateway.py index 73910813662..af7f8f69748 100644 --- a/tools/managed_tool_gateway.py +++ b/tools/managed_tool_gateway.py @@ -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)}",