diff --git a/agent/model_metadata.py b/agent/model_metadata.py index 37b991914cb1..1e9b66ef3090 100644 --- a/agent/model_metadata.py +++ b/agent/model_metadata.py @@ -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 diff --git a/agent/transcription_registry.py b/agent/transcription_registry.py index d84f93b19e46..b04a8593a576 100644 --- a/agent/transcription_registry.py +++ b/agent/transcription_registry.py @@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({ "openai", "mistral", "xai", + "elevenlabs", + "deepinfra", }) diff --git a/agent/video_gen_registry.py b/agent/video_gen_registry.py index 549c1c10cbf4..c4d28e39ed4b 100644 --- a/agent/video_gen_registry.py +++ b/agent/video_gen_registry.py @@ -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.""" diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 7d1ebdc89538..8804bcbeca15 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -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", diff --git a/hermes_cli/config.py b/hermes_cli/config.py index f7cd6215708d..da95d4bdf2b7 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -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", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index b7131fab3c43..f10fef3fd958 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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) diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 4eda7f3e1bdf..425e191ee34c 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -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() diff --git a/plugins/image_gen/deepinfra/__init__.py b/plugins/image_gen/deepinfra/__init__.py index 4142e5ee96e3..1ef5dcfb6c7e 100644 --- a/plugins/image_gen/deepinfra/__init__.py +++ b/plugins/image_gen/deepinfra/__init__.py @@ -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", diff --git a/plugins/model-providers/deepinfra/__init__.py b/plugins/model-providers/deepinfra/__init__.py index b1fd89e7529e..afa06548db16 100644 --- a/plugins/model-providers/deepinfra/__init__.py +++ b/plugins/model-providers/deepinfra/__init__.py @@ -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 diff --git a/scripts/release.py b/scripts/release.py index 84a39b54c377..a40320329b32 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -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", diff --git a/tests/agent/test_transcription_registry.py b/tests/agent/test_transcription_registry.py index 9c3b93f0d2c4..41b151dc6785 100644 --- a/tests/agent/test_transcription_registry.py +++ b/tests/agent/test_transcription_registry.py @@ -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) diff --git a/tests/agent/test_video_gen_registry.py b/tests/agent/test_video_gen_registry.py index a0c6bda04926..be48bf900114 100644 --- a/tests/agent/test_video_gen_registry.py +++ b/tests/agent/test_video_gen_registry.py @@ -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 diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 56b274acad23..a151eb0c2665 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -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 diff --git a/tests/plugins/image_gen/test_deepinfra_provider.py b/tests/plugins/image_gen/test_deepinfra_provider.py index eee2ad16b0ec..5cd3c0ffcef0 100644 --- a/tests/plugins/image_gen/test_deepinfra_provider.py +++ b/tests/plugins/image_gen/test_deepinfra_provider.py @@ -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, + } diff --git a/tests/tools/test_config_null_guard.py b/tests/tools/test_config_null_guard.py index b1e9714d187a..30b63c783da0 100644 --- a/tests/tools/test_config_null_guard.py +++ b/tests/tools/test_config_null_guard.py @@ -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 ───────────────────────────────────────────────────────────── diff --git a/tests/tools/test_image_generation_plugin_dispatch.py b/tests/tools/test_image_generation_plugin_dispatch.py index 1fbb9f997979..f96da8d64df4 100644 --- a/tests/tools/test_image_generation_plugin_dispatch.py +++ b/tests/tools/test_image_generation_plugin_dispatch.py @@ -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 diff --git a/tools/image_generation_tool.py b/tools/image_generation_tool.py index 6722f4e1c227..ff46518bd3fc 100644 --- a/tools/image_generation_tool.py +++ b/tools/image_generation_tool.py @@ -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() diff --git a/tools/transcription_tools.py b/tools/transcription_tools.py index 6ec4813dcd77..39e92261a19c 100644 --- a/tools/transcription_tools.py +++ b/tools/transcription_tools.py @@ -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", }) diff --git a/tools/tts_tool.py b/tools/tts_tool.py index 67c1934a150c..77d7041d9181 100644 --- a/tools/tts_tool.py +++ b/tools/tts_tool.py @@ -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() # ===========================================================================