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:
@@ -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.
|
||||
|
||||
67
tests/hermes_cli/test_imagegen_managed_gateway.py
Normal file
67
tests/hermes_cli/test_imagegen_managed_gateway.py
Normal 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
|
||||
Reference in New Issue
Block a user