fix(deepinfra): harden multimodal provider routing

Prevent credential forwarding across catalog redirects, retain explicit opt-in semantics for paid media backends, fail closed on invalid provider configuration, avoid mixed-catalog and output-limit assumptions, and reserve native STT provider names.
This commit is contained in:
kshitijk4poor 2026-07-14 01:47:27 +05:30 committed by kshitij
parent fe002eb124
commit 2fc3f9c1ff
19 changed files with 211 additions and 318 deletions

View file

@ -47,7 +47,7 @@ def _resolve_requests_verify() -> bool | str:
# are preserved so the full model name reaches cache lookups and server queries.
_PROVIDER_PREFIXES: frozenset[str] = frozenset({
"openrouter", "nous", "openai-codex", "copilot", "copilot-acp",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek", "deepinfra",
"gemini", "ollama-cloud", "zai", "kimi-coding", "kimi-coding-cn", "stepfun", "minimax", "minimax-oauth", "minimax-cn", "anthropic", "deepseek",
"opencode-zen", "opencode-go", "kilocode", "alibaba", "novita",
"qwen-oauth",
"xiaomi",
@ -58,7 +58,7 @@ _PROVIDER_PREFIXES: frozenset[str] = frozenset({
# Common aliases
"google", "google-gemini", "google-ai-studio",
"glm", "z-ai", "z.ai", "zhipu", "github", "github-copilot",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek", "deep-infra",
"github-models", "kimi", "moonshot", "kimi-cn", "moonshot-cn", "claude", "deep-seek",
"ollama",
"stepfun", "opencode", "zen", "go", "kilo", "dashscope", "aliyun", "qwen",
"mimo", "xiaomi-mimo",
@ -461,7 +461,6 @@ _URL_TO_PROVIDER: Dict[str, str] = {
"generativelanguage.googleapis.com": "gemini",
"inference-api.nousresearch.com": "nous",
"api.deepseek.com": "deepseek",
"api.deepinfra.com": "deepinfra",
"api.githubcopilot.com": "copilot",
# Enterprise Copilot endpoints look like api.enterprise.githubcopilot.com,
# api.business.githubcopilot.com, etc. Match the suffix so context-window

View file

@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
"openai",
"mistral",
"xai",
"elevenlabs",
"deepinfra",
})

View file

@ -103,9 +103,10 @@ def get_active_provider() -> Optional[VideoGenProvider]:
if provider is not None:
return provider
logger.debug(
"video_gen.provider='%s' configured but not registered; falling back",
"video_gen.provider='%s' configured but not registered; failing closed",
configured,
)
return None
def _is_available_safe(p: VideoGenProvider) -> bool:
"""Wrap ``is_available()`` so a buggy provider doesn't kill resolution."""

View file

@ -442,14 +442,6 @@ PROVIDER_REGISTRY: Dict[str, ProviderConfig] = {
api_key_env_vars=("AZURE_FOUNDRY_API_KEY",),
base_url_env_var="AZURE_FOUNDRY_BASE_URL",
),
"deepinfra": ProviderConfig(
id="deepinfra",
name="DeepInfra",
auth_type="api_key",
inference_base_url="https://api.deepinfra.com/v1/openai",
api_key_env_vars=("DEEPINFRA_API_KEY",),
base_url_env_var="DEEPINFRA_BASE_URL",
),
}
# Auto-extend PROVIDER_REGISTRY with any api-key provider registered in
@ -1700,7 +1692,6 @@ def resolve_provider(
"tencent": "tencent-tokenhub", "tokenhub": "tencent-tokenhub",
"tencent-cloud": "tencent-tokenhub", "tencentmaas": "tencent-tokenhub",
"aws": "bedrock", "aws-bedrock": "bedrock", "amazon-bedrock": "bedrock", "amazon": "bedrock",
"deep-infra": "deepinfra",
"go": "opencode-go", "opencode-go-sub": "opencode-go",
"kilo": "kilocode", "kilo-code": "kilocode", "kilo-gateway": "kilocode",
"lmstudio": "lmstudio", "lm-studio": "lmstudio", "lm_studio": "lmstudio",

View file

@ -2064,11 +2064,9 @@ DEFAULT_CONFIG = {
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
# "" (auto) → use the active inference provider when it ships a built-in
# TTS backend (DeepInfra, OpenAI, xAI, …), else fall back to Edge. Set
# explicitly to pin a backend:
# Set explicitly to pin a backend:
# "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "deepinfra" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"provider": "",
"provider": "edge",
"edge": {
"voice": "en-US-AriaNeural",
# Popular: AriaNeural, JennyNeural, AndrewNeural, BrianNeural, SoniaNeural
@ -3734,22 +3732,6 @@ OPTIONAL_ENV_VARS = {
"category": "provider",
"advanced": True,
},
"DEEPINFRA_API_KEY": {
"description": "DeepInfra API key (100+ top models via api.deepinfra.com)",
"prompt": "DeepInfra API Key",
"url": "https://deepinfra.com/dash/api_keys",
"password": True,
"category": "provider",
},
"DEEPINFRA_BASE_URL": {
"description": "DeepInfra base URL override",
"prompt": "DeepInfra base URL (leave empty for default)",
"url": None,
"password": False,
"category": "provider",
"advanced": True,
},
# ── Tool API keys ──
"EXA_API_KEY": {
"description": "Exa API key for AI-native web search and contents",

View file

@ -3185,7 +3185,6 @@ def select_provider_and_model(args=None):
"ollama-cloud",
"tencent-tokenhub",
"lmstudio",
"deepinfra",
} or _is_profile_api_key_provider(selected_provider):
_model_flow_api_key_provider(config, selected_provider, current_model)

View file

@ -517,12 +517,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"glm-4.7",
"MiniMax-M2.5",
],
# DeepInfra: empty by design. The live catalog at
# _fetch_deepinfra_models() (filtered by the ``chat`` surface tag) is
# the only source of truth. Hardcoding ids here would rot as models
# are deprecated upstream; the picker shows "no models" when the
# catalog is unreachable, which is honest.
"deepinfra": [],
# Curated HF model list — only agentic models that map to OpenRouter defaults.
"huggingface": [
"moonshotai/Kimi-K2.5",
@ -1094,7 +1088,6 @@ CANONICAL_PROVIDERS: list[ProviderEntry] = [
ProviderEntry("bedrock", "AWS Bedrock", "AWS Bedrock (Claude, Nova, Llama, DeepSeek; IAM or API key)"),
ProviderEntry("azure-foundry", "Azure Foundry", "Azure Foundry (OpenAI-style or Anthropic-style endpoint, your Azure AI deployment)"),
ProviderEntry("qwen-oauth", "Qwen OAuth (Portal)", "Qwen OAuth (Reuses local Qwen CLI login)"),
ProviderEntry("deepinfra", "DeepInfra", "DeepInfra (100+ top models)"),
]
# Auto-extend CANONICAL_PROVIDERS with any provider registered in providers/
@ -1305,7 +1298,6 @@ _PROVIDER_ALIASES = {
"lm_studio": "lmstudio",
"ollama": "custom", # bare "ollama" = local; use "ollama-cloud" for cloud
"ollama_cloud": "ollama-cloud",
"deep-infra": "deepinfra",
}
@ -2397,9 +2389,10 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
return merged
return list(_PROVIDER_MODELS.get("anthropic", []))
if normalized == "deepinfra":
live = _fetch_deepinfra_models()
if live:
return live
# DeepInfra's generic /models endpoint mixes chat, image, video,
# speech, and embedding models. The tagged catalog helper is the only
# safe source for the chat picker, including its empty/failure result.
return _fetch_deepinfra_models() or []
if normalized == "ollama-cloud":
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
if live:
@ -3710,7 +3703,7 @@ def _fetch_deepinfra_catalog(
req = urllib.request.Request(url, headers=headers)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
with _urlopen_model_catalog_request(req, timeout=timeout) as resp:
payload = json.loads(resp.read().decode())
except Exception:
_deepinfra_catalog_neg_cache[cache_key] = time.monotonic()

View file

@ -152,6 +152,10 @@ class DeepInfraImageGenProvider(ImageGenProvider):
return rows[0].get("id")
return None
def capabilities(self) -> Dict[str, Any]:
"""DeepInfra's OpenAI-compatible generation surface is text-only."""
return {"modalities": ["text"], "max_reference_images": 0}
def get_setup_schema(self) -> Dict[str, Any]:
return {
"name": "DeepInfra",
@ -175,6 +179,18 @@ class DeepInfraImageGenProvider(ImageGenProvider):
prompt = (prompt or "").strip()
aspect = resolve_aspect_ratio(aspect_ratio)
if kwargs.get("image_url") or kwargs.get("reference_image_urls"):
return error_response(
error=(
"DeepInfra image generation is text-to-image only in this "
"backend; image_url and reference_image_urls are unsupported."
),
error_type="modality_unsupported",
provider="deepinfra",
prompt=prompt,
aspect_ratio=aspect,
)
if not prompt:
return error_response(
error="Prompt is required and must be a non-empty string",

View file

@ -57,22 +57,10 @@ deepinfra = _DeepInfraProfile(
env_vars=("DEEPINFRA_API_KEY", "DEEPINFRA_BASE_URL"),
base_url="https://api.deepinfra.com/v1/openai",
auth_type="api_key",
# Default output cap when the user hasn't set ``agent.max_tokens``.
# Without this the profile inherits ``None`` and the transport sends no
# ``max_tokens`` (chat_completions.py: the ``elif profile_max`` branch
# is skipped because ``None`` is falsy), so DeepInfra applies its small
# server-side default (~8-16K). Tool-heavy runs (e.g. cron jobs doing
# many web_search calls before composing) then exhaust that budget on
# tool results, hit ``finish_reason='length'``, and fail after the
# 3 continuation retries in conversation_loop.py.
#
# 128K gives ample room for tool-result processing + final output.
# Safe across the whole catalog despite per-model limits ranging from
# 4K (Gryphe/MythoMax) to 1M (DeepSeek-V4): DeepInfra silently CLAMPS
# max_tokens to each model's own limit rather than rejecting it
# (verified live). Users who set ``agent.max_tokens`` still win — their
# value takes priority over this profile default in the transport.
default_max_tokens=131072,
# The catalog spans models with different output limits. Omitting a
# provider-wide default lets DeepInfra apply its documented per-model cap;
# an explicit user ``agent.max_tokens`` still passes through normally.
default_max_tokens=None,
# Auxiliary model — cheap/fast chat model the same provider uses for
# side tasks (context compression, session search, web extract,
# vision). This is the *only* hardcoded DeepInfra model in the

View file

@ -1453,6 +1453,7 @@ AUTHOR_MAP = {
"michaelfackerell@gmail.com": "MikeFac",
"18024642@qq.com": "GuyCui",
"eumael.mkt@gmail.com": "maelrx",
"georgi@deepinfra.com": "ats3v",
# v0.11.0 additions
"benbarclay@gmail.com": "benbarclay",
"lijiawen@umich.edu": "Jiawen-lee",

View file

@ -91,7 +91,16 @@ class TestRegistration:
@pytest.mark.parametrize(
"builtin",
["local", "local_command", "groq", "openai", "mistral", "xai"],
[
"local",
"local_command",
"groq",
"openai",
"mistral",
"xai",
"elevenlabs",
"deepinfra",
],
)
def test_rejects_builtin_shadow_with_warning(self, builtin, caplog):
p = _FakeProvider(name=builtin)

View file

@ -113,9 +113,8 @@ class TestGetActiveProvider:
active = video_gen_registry.get_active_provider()
assert active is not None and active.name == "fal"
def test_unknown_config_falls_back(self, tmp_path, monkeypatch):
"""If video_gen.provider names a provider that isn't registered,
the single-provider fallback still applies."""
def test_unknown_explicit_config_fails_closed(self, tmp_path, monkeypatch):
"""A typo must not silently route a paid request to another backend."""
import yaml
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
@ -123,5 +122,4 @@ class TestGetActiveProvider:
yaml.safe_dump({"video_gen": {"provider": "ghost"}})
)
video_gen_registry.register_provider(_FakeProvider("only"))
active = video_gen_registry.get_active_provider()
assert active is not None and active.name == "only"
assert video_gen_registry.get_active_provider() is None

View file

@ -1374,8 +1374,10 @@ class TestFetchDeepInfraModels:
{"id": "stabilityai/stable-diffusion-xl-base-1.0", "metadata": {}},
]}).encode()
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **kw: _Resp())
import hermes_cli.models as models
monkeypatch.setattr(
models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp()
)
from hermes_cli.models import _fetch_deepinfra_models
result = _fetch_deepinfra_models()
@ -1399,19 +1401,69 @@ class TestFetchDeepInfraModels:
{"id": "meta-llama/Llama-3-70B-Instruct", "metadata": {}},
]}).encode()
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **kw: _Resp())
import hermes_cli.models as models
monkeypatch.setattr(
models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp()
)
from hermes_cli.models import _fetch_deepinfra_models
result = _fetch_deepinfra_models()
assert result == ["meta-llama/Llama-3-70B-Instruct"]
def test_returns_none_on_network_failure(self, monkeypatch):
monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key")
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **kw: (_ for _ in ()).throw(Exception("timeout")))
import hermes_cli.models as models
monkeypatch.setattr(
models,
"_urlopen_model_catalog_request",
lambda *a, **kw: (_ for _ in ()).throw(Exception("timeout")),
)
from hermes_cli.models import _fetch_deepinfra_models
assert _fetch_deepinfra_models() is None
def test_catalog_uses_credential_safe_opener(self, monkeypatch):
import hermes_cli.models as models
seen = {}
class _Resp:
def __enter__(self):
return self
def __exit__(self, *args):
return False
def read(self):
return json.dumps({"data": []}).encode()
def _safe_open(request, *, timeout):
seen["authorization"] = request.get_header("Authorization")
seen["timeout"] = timeout
return _Resp()
monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key")
monkeypatch.setattr(models, "_urlopen_model_catalog_request", _safe_open)
assert models._fetch_deepinfra_catalog(force_refresh=True) == []
assert seen == {"authorization": "Bearer test-key", "timeout": 5.0}
def test_empty_filtered_catalog_never_falls_back_to_mixed_profile_catalog(
self, monkeypatch
):
import hermes_cli.models as models
from providers import get_provider_profile
profile = get_provider_profile("deepinfra")
assert profile is not None
monkeypatch.setattr(models, "_fetch_deepinfra_models", lambda: None)
monkeypatch.setattr(
profile,
"fetch_models",
lambda **kwargs: ["black-forest-labs/FLUX-1-dev"],
)
monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key")
assert models.provider_model_ids("deepinfra") == []
def test_excludes_non_chat_models(self, monkeypatch):
monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key")
@ -1432,8 +1484,10 @@ class TestFetchDeepInfraModels:
{"id": "nvidia/sdxl-turbo", "metadata": {}},
]}).encode()
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **kw: _Resp())
import hermes_cli.models as models
monkeypatch.setattr(
models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp()
)
from hermes_cli.models import _fetch_deepinfra_models
result = _fetch_deepinfra_models()
@ -1483,13 +1537,16 @@ class TestDeepInfraTagFiltering:
# null metadata — stub model, must be skipped
{"id": "stub-model", "metadata": None},
]}
import urllib.request
from hermes_cli.models import _fetch_deepinfra_models_by_tag
import hermes_cli.models as _m
for surface in ("chat", "image-gen", "tts", "stt", "embed"):
monkeypatch.setattr(urllib.request, "urlopen", _make_urlopen_returning(payload))
monkeypatch.setattr(
_m,
"_urlopen_model_catalog_request",
_make_urlopen_returning(payload),
)
# Reset cache between iterations so each surface re-parses the payload.
import hermes_cli.models as _m
_m._deepinfra_catalog_cache.clear()
got = _fetch_deepinfra_models_by_tag(surface)
assert got is not None
@ -1508,9 +1565,9 @@ class TestDeepInfraTagFiltering:
assert surface in item["metadata"]["tags"]
def test_returns_none_on_network_failure(self, monkeypatch):
import urllib.request
import hermes_cli.models as models
monkeypatch.setattr(
urllib.request, "urlopen",
models, "_urlopen_model_catalog_request",
lambda *a, **kw: (_ for _ in ()).throw(Exception("timeout")),
)
from hermes_cli.models import _fetch_deepinfra_models_by_tag, _fetch_deepinfra_pricing
@ -1544,8 +1601,12 @@ class TestDeepInfraPricingFetcher:
# non-chat — must not appear
{"id": "vendor/model-image", "metadata": {"tags": ["image-gen"], "pricing": {"per_image_unit": 0.05}}},
]}
import urllib.request
monkeypatch.setattr(urllib.request, "urlopen", _make_urlopen_returning(payload))
import hermes_cli.models as models
monkeypatch.setattr(
models,
"_urlopen_model_catalog_request",
_make_urlopen_returning(payload),
)
from hermes_cli.models import get_pricing_for_provider
# get_pricing_for_provider → _fetch_deepinfra_pricing dispatch path
@ -1564,6 +1625,9 @@ class TestDeepInfraProviderProfile:
def test_profile_registered_with_alias_and_aux(self):
from providers import get_provider_profile
from agent.auxiliary_client import _get_aux_model_for_provider
from hermes_cli.auth import PROVIDER_REGISTRY, resolve_provider
from hermes_cli.config import OPTIONAL_ENV_VARS
from hermes_cli.models import CANONICAL_PROVIDERS
profile = get_provider_profile("deepinfra")
assert profile is not None
@ -1571,6 +1635,11 @@ class TestDeepInfraProviderProfile:
assert profile.auth_type == "api_key"
# Alias resolves to the same profile.
assert get_provider_profile("deep-infra") is profile
assert resolve_provider("deep-infra") == "deepinfra"
assert PROVIDER_REGISTRY["deepinfra"].inference_base_url == profile.base_url
assert any(entry.slug == "deepinfra" for entry in CANONICAL_PROVIDERS)
assert OPTIONAL_ENV_VARS["DEEPINFRA_API_KEY"]["password"] is True
assert OPTIONAL_ENV_VARS["DEEPINFRA_BASE_URL"]["password"] is False
# Aux model is resolved via the profile (not via the legacy
# _API_KEY_PROVIDER_AUX_MODELS_FALLBACK dict, which has no
# deepinfra entry).
@ -1579,20 +1648,11 @@ class TestDeepInfraProviderProfile:
# of truth. Pin the shape only, not contents.
assert isinstance(profile.fallback_models, tuple)
def test_profile_sets_default_max_tokens(self):
"""A non-None default_max_tokens must be advertised so the transport's
``elif profile_max`` branch fires when the user hasn't configured
``agent.max_tokens``. Without it DeepInfra applies a small server
default and tool-heavy runs truncate (finish_reason='length')."""
def test_profile_does_not_force_one_output_cap_across_mixed_catalog(self):
"""DeepInfra model output limits vary, so the server default is safest."""
from providers import get_provider_profile
profile = get_provider_profile("deepinfra")
assert profile.default_max_tokens is not None
assert profile.default_max_tokens > 0
# get_max_tokens() returns the static default regardless of model
# (DeepInfra clamps per-model server-side, so one value is safe).
assert profile.get_max_tokens(None) == profile.default_max_tokens
assert (
profile.get_max_tokens("deepseek-ai/DeepSeek-V4-Flash")
== profile.default_max_tokens
)
assert profile is not None
assert profile.default_max_tokens is None
assert profile.get_max_tokens("deepseek-ai/DeepSeek-V4-Flash") is None

View file

@ -44,7 +44,7 @@ def test_list_models_filters_by_image_gen_tag(monkeypatch):
"""Plugin-side wiring: list_models() returns only ``image-gen``-tagged
catalog entries and surfaces pricing + default dims when present."""
import json
import urllib.request
import hermes_cli.models as models
class _Resp:
def __enter__(self): return self
@ -59,7 +59,9 @@ def test_list_models_filters_by_image_gen_tag(monkeypatch):
}},
]}).encode()
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **kw: _Resp())
monkeypatch.setattr(
models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp()
)
rows = deepinfra_plugin.DeepInfraImageGenProvider().list_models()
ids = {row["id"] for row in rows}
assert ids == {"vendor/img"}
@ -95,3 +97,33 @@ def test_generate_calls_openai_sdk_with_deepinfra_base_url(monkeypatch):
assert "deepinfra" in captured["base_url"]
assert captured["api_key"] == "test-key"
assert captured["kwargs"]["model"] == "vendor/test-img"
@pytest.mark.parametrize(
"kwargs",
[
{"image_url": "https://example.com/source.png"},
{"reference_image_urls": ["https://example.com/reference.png"]},
],
)
def test_generate_rejects_unsupported_edit_inputs_without_calling_sdk(
monkeypatch, kwargs
):
monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img")
fake_openai = MagicMock()
with patch.dict("sys.modules", {"openai": fake_openai}):
result = deepinfra_plugin.DeepInfraImageGenProvider().generate(
prompt="edit this", **kwargs
)
assert result["success"] is False
assert result["error_type"] == "modality_unsupported"
assert result["provider"] == "deepinfra"
fake_openai.OpenAI.assert_not_called()
def test_capabilities_advertise_text_to_image_only():
assert deepinfra_plugin.DeepInfraImageGenProvider().capabilities() == {
"modalities": ["text"],
"max_reference_images": 0,
}

View file

@ -17,18 +17,14 @@ class TestTTSProviderNullGuard:
"""YAML ``tts: {provider: null}`` should fall back to default."""
from tools.tts_tool import _get_provider, DEFAULT_PROVIDER
# Pin the active inference provider to a non-TTS one so the
# active-provider fallback doesn't fire — isolates the null guard.
with patch("tools.tts_tool._active_model_provider", return_value="anthropic"):
result = _get_provider({"provider": None})
result = _get_provider({"provider": None})
assert result == DEFAULT_PROVIDER.lower().strip()
def test_missing_provider_returns_default(self):
"""No ``provider`` key + non-TTS active provider should return default."""
from tools.tts_tool import _get_provider, DEFAULT_PROVIDER
with patch("tools.tts_tool._active_model_provider", return_value="anthropic"):
result = _get_provider({})
result = _get_provider({})
assert result == DEFAULT_PROVIDER.lower().strip()
def test_valid_provider_passed_through(self):
@ -37,19 +33,12 @@ class TestTTSProviderNullGuard:
result = _get_provider({"provider": "OPENAI"})
assert result == "openai"
def test_falls_back_to_active_tts_capable_provider_when_available(self):
"""No explicit tts.provider + a TTS-capable, credentialled active
provider use it. DeepInfra/OpenAI are in BUILTIN_TTS_PROVIDERS, so a
single-provider deployment gets matching TTS without configuring
tts.provider but only when the backend can authenticate."""
from tools.tts_tool import _get_provider
def test_missing_provider_keeps_free_default_with_cloud_credentials(self):
"""A chat-provider key must not silently opt the user into paid TTS."""
from tools.tts_tool import _get_provider, DEFAULT_PROVIDER
with patch("tools.tts_tool._active_model_provider", return_value="deepinfra"), \
patch("tools.tts_tool._tts_provider_available", return_value=True):
assert _get_provider({}) == "deepinfra"
with patch("tools.tts_tool._active_model_provider", return_value="openai"), \
patch("tools.tts_tool._tts_provider_available", return_value=True):
assert _get_provider({"provider": None}) == "openai"
assert _get_provider({}) == DEFAULT_PROVIDER
assert _get_provider({"provider": None}) == DEFAULT_PROVIDER
def test_active_provider_without_credentials_keeps_edge(self):
"""A TTS-capable active provider that can't authenticate must NOT
@ -57,16 +46,13 @@ class TestTTSProviderNullGuard:
errors for a credential-less deployment)."""
from tools.tts_tool import _get_provider, DEFAULT_PROVIDER
with patch("tools.tts_tool._active_model_provider", return_value="openai"), \
patch("tools.tts_tool._tts_provider_available", return_value=False):
assert _get_provider({}) == DEFAULT_PROVIDER.lower().strip()
assert _get_provider({}) == DEFAULT_PROVIDER.lower().strip()
def test_explicit_provider_wins_over_active(self):
"""An explicit tts.provider always overrides the active-provider fallback."""
from tools.tts_tool import _get_provider
with patch("tools.tts_tool._active_model_provider", return_value="deepinfra"):
assert _get_provider({"provider": "edge"}) == "edge"
assert _get_provider({"provider": "edge"}) == "edge"
# ── Web tools ─────────────────────────────────────────────────────────────

View file

@ -98,91 +98,27 @@ class TestPluginDispatch:
assert payload["provider"] == "codex"
assert payload["aspect_ratio"] == "portrait"
def test_auto_dispatches_to_matching_provider_when_image_gen_unset(self, monkeypatch):
"""``image_gen.provider`` unset → dispatch to the registry's active
provider (resolved via _resolve_active_image_provider), else fall
through (None)."""
def test_unset_provider_keeps_legacy_fal_path(self, monkeypatch):
"""An unrelated API key must not opt the user into paid image generation."""
from tools import image_generation_tool
from agent import image_gen_registry as registry_module
from hermes_cli import plugins as plugins_module
monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: None)
monkeypatch.setattr(image_generation_tool, "_resolve_active_image_provider", lambda: "codex")
monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **kw: None)
image_gen_registry.register_provider(_FakeCodexProvider())
monkeypatch.setattr(
registry_module, "get_provider",
lambda name: _FakeCodexProvider() if name == "codex" else None,
)
# Active provider resolved → auto-dispatch.
dispatched = image_generation_tool._dispatch_to_plugin_provider("draw cat", "landscape")
assert dispatched is not None
assert json.loads(dispatched)["provider"] == "codex"
# Nothing available (or legacy FAL) → returns None (caller drops to
# the in-tree FAL pipeline).
monkeypatch.setattr(image_generation_tool, "_resolve_active_image_provider", lambda: None)
assert image_generation_tool._dispatch_to_plugin_provider("draw cat", "landscape") is None
monkeypatch.setattr(image_generation_tool, "_resolve_active_image_provider", lambda: "fal")
assert image_generation_tool._dispatch_to_plugin_provider("draw cat", "landscape") is None
def test_deepinfra_bootstrap_no_config_changes_needed(self, monkeypatch):
"""Bootstrap regression: with ``DEEPINFRA_API_KEY`` set and no FAL
credentials, the dispatcher must route to the bundled DeepInfra plugin
without any ``image_gen.provider`` entry i.e. the user never sees the
FAL ``FAL_KEY isn't set`` fallback. The unset-config path now goes
through the availability-filtered registry (get_active_provider), so
the single credentialled backend (DeepInfra) is selected automatically."""
def test_deepinfra_key_alone_does_not_select_image_backend(self, monkeypatch):
"""DeepInfra chat credentials do not imply consent to image billing."""
from tools import image_generation_tool
from hermes_cli import plugins as plugins_module
from plugins.image_gen import deepinfra as deepinfra_plugin
from plugins.image_gen.deepinfra import DeepInfraImageGenProvider
# Simulate: DEEPINFRA_API_KEY set, no FAL_KEY, fresh-out-of-box config.
monkeypatch.setenv("DEEPINFRA_API_KEY", "sk-test-bootstrap")
monkeypatch.setenv("DEEPINFRA_API_KEY", "«redacted:sk-…»")
monkeypatch.delenv("FAL_KEY", raising=False)
monkeypatch.setattr(image_generation_tool, "_read_configured_image_provider", lambda: None)
monkeypatch.setattr(image_generation_tool, "_read_configured_image_model", lambda: None)
monkeypatch.setattr(plugins_module, "_ensure_plugins_discovered", lambda *a, **kw: None)
assert image_generation_tool._dispatch_to_plugin_provider("a cat", "square") is None
# Only DeepInfra is registered (autouse fixture reset the registry), so
# the registry's single-available fallback selects it — no bespoke
# model.provider inference needed.
image_gen_registry.register_provider(DeepInfraImageGenProvider())
def test_requirements_ignore_unselected_paid_plugin(self, monkeypatch):
from tools import image_generation_tool
# Stub the live catalog so DeepInfra has at least one model to pick.
from hermes_cli import models as models_mod
monkeypatch.setattr(image_generation_tool, "check_fal_api_key", lambda: False)
monkeypatch.setattr(
models_mod, "_fetch_deepinfra_models_by_tag",
lambda tag, **kw: (
[{"id": "black-forest-labs/FLUX.1-dev", "metadata": {}}]
if tag == "image-gen" else []
),
image_generation_tool, "_read_configured_image_provider", lambda: None
)
# Avoid a real network fetch when caching the (stubbed) delivery URL.
monkeypatch.setattr(
deepinfra_plugin, "save_url_image",
lambda url, **kw: __import__("pathlib").Path("/tmp/deepinfra_test.png"),
)
# Stub openai so we don't hit the network.
import openai
class _Images:
def generate(self, **kw):
class _Resp:
class _Data:
b64_json = None
url = "https://example.com/img.png"
data = [_Data()]
return _Resp()
class _Client:
def __init__(self, **kw):
self.images = _Images()
monkeypatch.setattr(openai, "OpenAI", _Client)
dispatched = image_generation_tool._dispatch_to_plugin_provider("a cat", "square")
assert dispatched is not None, "auto-resolution must dispatch to DeepInfra — falling through to FAL is the bug"
payload = json.loads(dispatched)
assert payload["provider"] == "deepinfra"
assert payload["model"] == "black-forest-labs/FLUX.1-dev"
assert image_generation_tool.check_image_generation_requirements() is False

View file

@ -1084,18 +1084,7 @@ def _build_no_backend_setup_message() -> str:
def check_image_generation_requirements() -> bool:
"""True if any image gen backend is available.
Providers are considered in this order:
1. The in-tree FAL backend (FAL_KEY or managed gateway).
2. Any plugin-registered provider whose ``is_available()`` returns True.
Plugins win only when the in-tree FAL path is NOT ready, which matches
the historical behavior: shipping hermes with a FAL key configured
should still expose the tool. The active selection among ready
providers is resolved per-call by ``image_gen.provider``.
"""
"""True if FAL or the explicitly configured image backend is available."""
try:
if check_fal_api_key():
# Trigger the lazy fal_client import here as the SDK presence
@ -1107,22 +1096,21 @@ def check_image_generation_requirements() -> bool:
except ImportError:
pass
# Probe plugin providers. Discovery is idempotent and cheap.
configured = _read_configured_image_provider()
if not configured or configured == "fal":
return False
# Probe only the explicitly selected plugin. Merely possessing a cloud
# provider key must not opt a user into a paid image-generation backend.
try:
from agent.image_gen_registry import list_providers
from agent.image_gen_registry import get_provider
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
for provider in list_providers():
try:
if provider.is_available():
return True
except Exception:
continue
provider = get_provider(configured)
return bool(provider and provider.is_available())
except Exception:
pass
return False
return False
# ---------------------------------------------------------------------------
@ -1271,29 +1259,6 @@ def _read_configured_image_provider():
return None
def _resolve_active_image_provider() -> Optional[str]:
"""Return the registry's active image-gen provider name, or ``None``.
Used only when ``image_gen.provider`` is unset. Delegates to
:func:`agent.image_gen_registry.get_active_provider`, which selects the
single provider that has credentials (else the legacy FAL preference)
the same availability-filtered fallback the video surface uses. So a
box whose only image credential is ``DEEPINFRA_API_KEY`` auto-selects
DeepInfra, while a box with FAL credentials keeps FAL, without this tool
re-implementing provider inference or env auto-detect.
"""
try:
from agent.image_gen_registry import get_active_provider
from hermes_cli.plugins import _ensure_plugins_discovered
_ensure_plugins_discovered()
provider = get_active_provider()
if provider is not None:
return provider.name
except Exception as exc:
logger.debug("image_gen active-provider resolution skipped: %s", exc)
return None
def _dispatch_to_plugin_provider(
prompt: str,
aspect_ratio: str,
@ -1316,17 +1281,8 @@ def _dispatch_to_plugin_provider(
route to its edit endpoint.
"""
configured = _read_configured_image_provider()
if configured == "fal":
return None # explicit opt-in to legacy FAL
if not configured:
# Let the registry pick the active backend (single available provider,
# else legacy FAL preference). ``fal`` or nothing → fall through to the
# in-tree FAL pipeline; any other available backend (e.g. DeepInfra on
# a box whose only image credential is DEEPINFRA_API_KEY) dispatches.
active = _resolve_active_image_provider()
if not active or active == "fal":
return None
configured = active
if not configured or configured == "fal":
return None # unset/explicit FAL keeps the legacy FAL path
# Also read configured model so we can pass it to the plugin
configured_model = _read_configured_image_model()

View file

@ -230,7 +230,7 @@ def _try_lazy_install_stt() -> bool:
return False
# Names of the 6 STT providers with native handlers in this module.
# Names of the STT providers with native handlers in this module.
# Kept in sync with ``agent.transcription_registry._BUILTIN_NAMES`` —
# a regression test fails if they drift. The plugin hook from
# issue #30398-style follow-up rejects plugins registering under any
@ -243,6 +243,8 @@ BUILTIN_STT_PROVIDERS = frozenset({
"openai",
"mistral",
"xai",
"elevenlabs",
"deepinfra",
})

View file

@ -352,72 +352,14 @@ def _load_tts_config() -> Dict[str, Any]:
return {}
def _active_model_provider() -> str:
"""Return the active inference provider name.
Reuses :func:`agent.auxiliary_client._read_main_provider` so a runtime
provider switch (``--provider`` flag, per-session gateway provider,
fallback-model activation) is honored reading ``model.provider`` from
disk directly would miss those. Empty string when unavailable. Used only
as a TTS default hint see :func:`_get_provider`.
"""
try:
from agent.auxiliary_client import _read_main_provider
return (_read_main_provider() or "").lower().strip()
except Exception:
try:
from hermes_cli.config import load_config_readonly
return str((load_config_readonly().get("model") or {}).get("provider") or "").lower().strip()
except Exception:
return ""
# API-key env vars for the cloud TTS backends. Local backends (edge, piper,
# neutts, kittentts) are always available. Used to gate the auto-default: the
# active inference provider's TTS backend is only inherited when it can
# actually authenticate — otherwise we fall back to free Edge instead of
# silently switching a deployment onto a backend that will error at call time.
_TTS_PROVIDER_KEY_ENV_VARS: Dict[str, tuple] = {
"elevenlabs": ("ELEVENLABS_API_KEY",),
"openai": ("OPENAI_API_KEY",),
"minimax": ("MINIMAX_API_KEY",),
"xai": ("XAI_API_KEY",),
"mistral": ("MISTRAL_API_KEY",),
"gemini": ("GEMINI_API_KEY", "GOOGLE_API_KEY"),
"deepinfra": ("DEEPINFRA_API_KEY",),
}
def _tts_provider_available(name: str) -> bool:
"""Return True when TTS backend *name* has usable credentials.
Local/command backends (not in the key map) are always considered
available.
"""
env_vars = _TTS_PROVIDER_KEY_ENV_VARS.get(name)
if env_vars is None:
return True
return any((get_env_value(v) or "").strip() for v in env_vars)
def _get_provider(tts_config: Dict[str, Any]) -> str:
"""Get the configured TTS provider name.
"""Get the explicitly configured TTS provider or the free default.
When ``tts.provider`` is set it always wins. With no explicit provider,
fall back to the active inference provider *if* it ships a built-in TTS
backend AND that backend can authenticate so a single-provider
deployment gets matching TTS out of the box without silently switching a
credential-less deployment off the free Edge default. Anything else keeps
the historical Edge default. Provider-agnostic: no backend is
special-cased here.
Inference credentials do not imply consent to paid speech generation.
Users opt into cloud TTS by setting ``tts.provider`` (normally through
``hermes tools``); otherwise the historical Edge backend remains active.
"""
explicit = (tts_config.get("provider") or "").lower().strip()
if explicit:
return explicit
active = _active_model_provider()
if active and active in BUILTIN_TTS_PROVIDERS and _tts_provider_available(active):
return active
return DEFAULT_PROVIDER
return (tts_config.get("provider") or DEFAULT_PROVIDER).lower().strip()
# ===========================================================================