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:
rob-maron 2026-07-31 10:17:55 -04:00 committed by GitHub
parent 44e5641dac
commit 126ff7071b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 758 additions and 105 deletions

View file

@ -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(

View file

@ -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"})

View file

@ -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")

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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)}",