mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-20 15:33:54 +00:00
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:
parent
fe002eb124
commit
2fc3f9c1ff
19 changed files with 211 additions and 318 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ _BUILTIN_NAMES = frozenset({
|
|||
"openai",
|
||||
"mistral",
|
||||
"xai",
|
||||
"elevenlabs",
|
||||
"deepinfra",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue