fix(tools-config): stop clobbering image_gen.use_gateway on Nous-managed FAL picks

_select_plugin_image_gen_provider hardcoded image_gen.use_gateway = False.
The managed (Nous-subscription) flow writes use_gateway = True via
_write_provider_config, then this selector runs AFTER it — so picking FAL
through Nous Portal silently persisted provider: fal, use_gateway: false
and every generation billed the user's personal FAL_KEY instead of the
subscription (real incident: key drained to zero-balance lock while the
managed route sat unused).

Fix the class, not the site:
- _select_plugin_image_gen_provider gains the same use_gateway kwarg its
  video twin (_select_plugin_video_gen_provider) already had; all four
  call sites pass use_gateway=bool(managed_feature), matching the video
  call sites, TTS, STT, browser, and web.
- Active-provider detection (the checkmark in `hermes tools`): the
  image_gen_plugin_name branch now defers managed entries to the
  managed_feature branch and requires use_gateway OFF for direct-key
  entries — mirroring the video branch's existing guard, so a managed
  FAL pick and a direct-key FAL pick no longer both report active.

Runtime side (prefers_gateway("image_gen")) was already correct; the bug
was purely the setup-time writer.

Tests: new tests/hermes_cli/test_imagegen_managed_gateway.py (3 cases:
managed flag survives, direct pick still clears, image/video selector
contract parity). Sabotage-verified: restoring the hardcoded False fails
2/3. Neighboring hermes_cli provider/managed suites: 180 passed.
This commit is contained in:
Teknium
2026-08-15 03:57:28 -07:00
parent bb4f680f22
commit fe63353cbb
2 changed files with 92 additions and 9 deletions

View File

@@ -3671,9 +3671,17 @@ def _is_provider_active(
) -> bool:
"""Check if a provider entry matches the currently active config."""
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
if plugin_name and not provider.get("managed_nous_feature"):
# Managed (Nous-subscription) entries fall through to the
# managed_feature branch below, which also checks use_gateway —
# otherwise a managed FAL pick and a direct-key FAL pick would both
# report active for the same provider name (video already guards).
image_cfg = config.get("image_gen", {})
return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name
if not (isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name):
return False
# A direct-key entry is only active when the managed route is OFF —
# mirror of the managed branch's use_gateway check.
return not is_truthy_value(image_cfg.get("use_gateway"), default=False)
video_plugin_name = provider.get("video_gen_plugin_name")
if video_plugin_name and not provider.get("managed_nous_feature"):
@@ -4026,14 +4034,22 @@ def _configure_xai_imagine_storage(section_name: str, config: dict) -> None:
_print_success(" xAI stored public URLs enabled without automatic expiry")
def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
"""Persist a plugin-backed image generation provider selection."""
def _select_plugin_image_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None:
"""Persist a plugin-backed image generation provider selection.
``use_gateway`` mirrors :func:`_select_plugin_video_gen_provider`: a
provider picked through the Nous-managed flow must keep routing through
the gateway. Hardcoding ``False`` here silently flipped Nous-managed FAL
picks onto the user's personal FAL_KEY — _write_provider_config sets
``image_gen.use_gateway = True`` for a managed pick, and this function
runs AFTER it, so the hardcoded value clobbered the managed flag.
"""
img_cfg = config.setdefault("image_gen", {})
if not isinstance(img_cfg, dict):
img_cfg = {}
config["image_gen"] = img_cfg
img_cfg["provider"] = plugin_name
img_cfg["use_gateway"] = False
img_cfg["use_gateway"] = use_gateway
_print_success(f" image_gen.provider set to: {plugin_name}")
_configure_imagegen_model_for_plugin(plugin_name, config)
if plugin_name == "xai":
@@ -4386,7 +4402,7 @@ def _configure_provider(
# and route model selection to the plugin's own catalog.
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
_select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature))
return
# Plugin-registered video_gen provider — same flow, different
# registry.
@@ -4471,7 +4487,7 @@ def _configure_provider(
_print_success(f" {provider['name']} configured!")
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
_select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature))
return
video_plugin = provider.get("video_gen_plugin_name")
if video_plugin:
@@ -4917,7 +4933,7 @@ def _reconfigure_provider(
_print_info(" Requests for this tool will be billed to your Nous subscription.")
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
_select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature))
return
# Plugin-registered video_gen provider — same flow, different registry.
video_plugin = provider.get("video_gen_plugin_name")
@@ -4959,7 +4975,7 @@ def _reconfigure_provider(
# Imagegen backends prompt for model selection on reconfig too.
plugin_name = provider.get("image_gen_plugin_name")
if plugin_name:
_select_plugin_image_gen_provider(plugin_name, config)
_select_plugin_image_gen_provider(plugin_name, config, use_gateway=bool(managed_feature))
return
# Plugin-registered video_gen provider — same flow, different registry.

View File

@@ -0,0 +1,67 @@
"""Regression tests for image_gen use_gateway persistence (managed FAL clobber).
Bug: ``_select_plugin_image_gen_provider`` hardcoded
``image_gen.use_gateway = False``. When a user picked FAL through the
Nous-subscription managed flow, ``_write_provider_config`` first set
``use_gateway = True`` — then the image selector ran and clobbered it back
to False, silently routing every generation through the user's personal
FAL_KEY instead of the Nous Tool Gateway (real incident: personal key
drained to zero while the subscription sat unused).
The video twin (``_select_plugin_video_gen_provider``) already accepted a
``use_gateway`` kwarg; these tests pin the image path to the same contract.
"""
from hermes_cli.tools_config import (
_select_plugin_image_gen_provider,
_select_plugin_video_gen_provider,
_write_provider_config,
)
def _quiet(monkeypatch):
import hermes_cli.tools_config as tc
monkeypatch.setattr(tc, "_print_success", lambda *a, **k: None)
monkeypatch.setattr(tc, "_print_info", lambda *a, **k: None, raising=False)
monkeypatch.setattr(tc, "_configure_imagegen_model_for_plugin", lambda *a, **k: None)
monkeypatch.setattr(tc, "_configure_videogen_model_for_plugin", lambda *a, **k: None)
def test_image_gen_selector_preserves_managed_gateway_flag(monkeypatch):
"""Managed pick: use_gateway=True must survive the selector."""
_quiet(monkeypatch)
config = {}
# The managed flow first writes the managed flag...
_write_provider_config(
{"image_gen_plugin_name": "fal"}, config, managed_feature="image_gen"
)
assert config["image_gen"]["use_gateway"] is True
# ...then the selector runs; passing the managed flag must NOT clobber it.
_select_plugin_image_gen_provider("fal", config, use_gateway=True)
assert config["image_gen"]["provider"] == "fal"
assert config["image_gen"]["use_gateway"] is True
def test_image_gen_selector_direct_key_pick_clears_gateway(monkeypatch):
"""Non-managed pick keeps the historical default: direct key, no gateway."""
_quiet(monkeypatch)
config = {"image_gen": {"use_gateway": True}}
_select_plugin_image_gen_provider("fal", config)
assert config["image_gen"]["provider"] == "fal"
assert config["image_gen"]["use_gateway"] is False
def test_image_and_video_selectors_share_the_gateway_contract(monkeypatch):
"""The two selectors are twins: same kwarg, same persistence behavior."""
_quiet(monkeypatch)
for use_gateway in (True, False):
config = {}
_select_plugin_image_gen_provider("fal", config, use_gateway=use_gateway)
_select_plugin_video_gen_provider("fal", config, use_gateway=use_gateway)
assert config["image_gen"]["use_gateway"] is use_gateway
assert config["video_gen"]["use_gateway"] is use_gateway