mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-30 16:01:49 +08:00
Compare commits
4 Commits
fix/plugin
...
hermes/buc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b49a9bff7a | ||
|
|
3ca0467040 | ||
|
|
627a6d1435 | ||
|
|
a915d66c72 |
176
tests/tools/test_budget_config.py
Normal file
176
tests/tools/test_budget_config.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
"""Unit tests for tools/budget_config.py.
|
||||||
|
|
||||||
|
Covers default values, resolve_threshold() priority chain
|
||||||
|
(pinned > tool_overrides > registry > default), immutability,
|
||||||
|
and the PINNED_THRESHOLDS escape-hatch for read_file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import dataclasses
|
||||||
|
import math
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools.budget_config import (
|
||||||
|
DEFAULT_BUDGET,
|
||||||
|
DEFAULT_PREVIEW_SIZE_CHARS,
|
||||||
|
DEFAULT_RESULT_SIZE_CHARS,
|
||||||
|
DEFAULT_TURN_BUDGET_CHARS,
|
||||||
|
PINNED_THRESHOLDS,
|
||||||
|
BudgetConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestModuleConstants:
|
||||||
|
"""Verify documented default values haven't drifted."""
|
||||||
|
|
||||||
|
def test_default_result_size(self):
|
||||||
|
assert DEFAULT_RESULT_SIZE_CHARS == 100_000
|
||||||
|
|
||||||
|
def test_default_turn_budget(self):
|
||||||
|
assert DEFAULT_TURN_BUDGET_CHARS == 200_000
|
||||||
|
|
||||||
|
def test_default_preview_size(self):
|
||||||
|
assert DEFAULT_PREVIEW_SIZE_CHARS == 1_500
|
||||||
|
|
||||||
|
|
||||||
|
class TestPinnedThresholds:
|
||||||
|
"""PINNED_THRESHOLDS – tools whose values must never be overridden."""
|
||||||
|
|
||||||
|
def test_read_file_is_inf(self):
|
||||||
|
assert PINNED_THRESHOLDS["read_file"] == float("inf")
|
||||||
|
assert math.isinf(PINNED_THRESHOLDS["read_file"])
|
||||||
|
|
||||||
|
def test_pinned_is_not_empty(self):
|
||||||
|
assert len(PINNED_THRESHOLDS) >= 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# BudgetConfig defaults
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetConfigDefaults:
|
||||||
|
"""BudgetConfig() should match the module-level defaults exactly."""
|
||||||
|
|
||||||
|
def test_default_result_size(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
assert cfg.default_result_size == DEFAULT_RESULT_SIZE_CHARS
|
||||||
|
|
||||||
|
def test_default_turn_budget(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
assert cfg.turn_budget == DEFAULT_TURN_BUDGET_CHARS
|
||||||
|
|
||||||
|
def test_default_preview_size(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
assert cfg.preview_size == DEFAULT_PREVIEW_SIZE_CHARS
|
||||||
|
|
||||||
|
def test_default_tool_overrides_empty(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
assert cfg.tool_overrides == {}
|
||||||
|
|
||||||
|
def test_default_budget_singleton_matches(self):
|
||||||
|
"""DEFAULT_BUDGET should equal a freshly constructed BudgetConfig."""
|
||||||
|
assert DEFAULT_BUDGET == BudgetConfig()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Immutability (frozen=True)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetConfigFrozen:
|
||||||
|
"""Frozen dataclass must reject attribute mutation."""
|
||||||
|
|
||||||
|
def test_cannot_set_default_result_size(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
cfg.default_result_size = 999
|
||||||
|
|
||||||
|
def test_cannot_set_turn_budget(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
cfg.turn_budget = 999
|
||||||
|
|
||||||
|
def test_cannot_set_preview_size(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
cfg.preview_size = 999
|
||||||
|
|
||||||
|
def test_cannot_set_tool_overrides(self):
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
||||||
|
cfg.tool_overrides = {"foo": 1}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Custom construction
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBudgetConfigCustom:
|
||||||
|
"""BudgetConfig can be created with non-default values."""
|
||||||
|
|
||||||
|
def test_custom_values(self):
|
||||||
|
cfg = BudgetConfig(
|
||||||
|
default_result_size=50_000,
|
||||||
|
turn_budget=100_000,
|
||||||
|
preview_size=500,
|
||||||
|
tool_overrides={"my_tool": 42},
|
||||||
|
)
|
||||||
|
assert cfg.default_result_size == 50_000
|
||||||
|
assert cfg.turn_budget == 100_000
|
||||||
|
assert cfg.preview_size == 500
|
||||||
|
assert cfg.tool_overrides == {"my_tool": 42}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_threshold() priority chain
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveThreshold:
|
||||||
|
"""Priority: pinned > tool_overrides > registry > default."""
|
||||||
|
|
||||||
|
def test_pinned_wins_over_override(self):
|
||||||
|
"""Even if tool_overrides contains read_file, pinned value wins."""
|
||||||
|
cfg = BudgetConfig(tool_overrides={"read_file": 1})
|
||||||
|
result = cfg.resolve_threshold("read_file")
|
||||||
|
assert result == float("inf")
|
||||||
|
|
||||||
|
def test_tool_override_wins_over_default(self):
|
||||||
|
"""tool_overrides should be returned before falling back to registry."""
|
||||||
|
cfg = BudgetConfig(tool_overrides={"my_tool": 42})
|
||||||
|
result = cfg.resolve_threshold("my_tool")
|
||||||
|
assert result == 42
|
||||||
|
|
||||||
|
@patch("tools.registry.registry")
|
||||||
|
def test_falls_back_to_registry(self, mock_registry):
|
||||||
|
"""When not pinned and not in overrides, delegate to registry."""
|
||||||
|
mock_registry.get_max_result_size.return_value = 77_777
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
result = cfg.resolve_threshold("some_tool")
|
||||||
|
mock_registry.get_max_result_size.assert_called_once_with(
|
||||||
|
"some_tool", default=DEFAULT_RESULT_SIZE_CHARS
|
||||||
|
)
|
||||||
|
assert result == 77_777
|
||||||
|
|
||||||
|
@patch("tools.registry.registry")
|
||||||
|
def test_registry_receives_custom_default(self, mock_registry):
|
||||||
|
"""Custom default_result_size flows through to registry call."""
|
||||||
|
mock_registry.get_max_result_size.return_value = 50_000
|
||||||
|
cfg = BudgetConfig(default_result_size=50_000)
|
||||||
|
cfg.resolve_threshold("unknown_tool")
|
||||||
|
mock_registry.get_max_result_size.assert_called_once_with(
|
||||||
|
"unknown_tool", default=50_000
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pinned_read_file_returns_inf(self):
|
||||||
|
"""Canonical case: read_file must always return inf."""
|
||||||
|
cfg = BudgetConfig()
|
||||||
|
assert cfg.resolve_threshold("read_file") == float("inf")
|
||||||
287
tests/tools/test_tool_backend_helpers.py
Normal file
287
tests/tools/test_tool_backend_helpers.py
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
"""Unit tests for tools/tool_backend_helpers.py.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- managed_nous_tools_enabled() feature flag
|
||||||
|
- normalize_browser_cloud_provider() coercion
|
||||||
|
- coerce_modal_mode() / normalize_modal_mode() validation
|
||||||
|
- has_direct_modal_credentials() detection
|
||||||
|
- resolve_modal_backend_state() backend selection matrix
|
||||||
|
- resolve_openai_audio_api_key() priority chain
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tools.tool_backend_helpers import (
|
||||||
|
coerce_modal_mode,
|
||||||
|
has_direct_modal_credentials,
|
||||||
|
managed_nous_tools_enabled,
|
||||||
|
normalize_browser_cloud_provider,
|
||||||
|
normalize_modal_mode,
|
||||||
|
resolve_modal_backend_state,
|
||||||
|
resolve_openai_audio_api_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# managed_nous_tools_enabled
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestManagedNousToolsEnabled:
|
||||||
|
"""Feature flag driven by HERMES_ENABLE_NOUS_MANAGED_TOOLS."""
|
||||||
|
|
||||||
|
def test_disabled_by_default(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", raising=False)
|
||||||
|
assert managed_nous_tools_enabled() is False
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("val", ["1", "true", "True", "yes"])
|
||||||
|
def test_enabled_when_truthy(self, monkeypatch, val):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
|
||||||
|
assert managed_nous_tools_enabled() is True
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("val", ["0", "false", "no", ""])
|
||||||
|
def test_disabled_when_falsy(self, monkeypatch, val):
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", val)
|
||||||
|
assert managed_nous_tools_enabled() is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# normalize_browser_cloud_provider
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestNormalizeBrowserCloudProvider:
|
||||||
|
"""Coerce arbitrary input to a lowercase browser provider key."""
|
||||||
|
|
||||||
|
def test_none_returns_default(self):
|
||||||
|
assert normalize_browser_cloud_provider(None) == "local"
|
||||||
|
|
||||||
|
def test_empty_string_returns_default(self):
|
||||||
|
assert normalize_browser_cloud_provider("") == "local"
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_default(self):
|
||||||
|
assert normalize_browser_cloud_provider(" ") == "local"
|
||||||
|
|
||||||
|
def test_known_provider_normalized(self):
|
||||||
|
assert normalize_browser_cloud_provider("BrowserBase") == "browserbase"
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert normalize_browser_cloud_provider(" Local ") == "local"
|
||||||
|
|
||||||
|
def test_integer_coerced(self):
|
||||||
|
result = normalize_browser_cloud_provider(42)
|
||||||
|
assert isinstance(result, str)
|
||||||
|
assert result == "42"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# coerce_modal_mode / normalize_modal_mode
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestCoerceModalMode:
|
||||||
|
"""Validate and coerce the requested modal execution mode."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("value", ["auto", "direct", "managed"])
|
||||||
|
def test_valid_modes_passthrough(self, value):
|
||||||
|
assert coerce_modal_mode(value) == value
|
||||||
|
|
||||||
|
def test_none_returns_auto(self):
|
||||||
|
assert coerce_modal_mode(None) == "auto"
|
||||||
|
|
||||||
|
def test_empty_string_returns_auto(self):
|
||||||
|
assert coerce_modal_mode("") == "auto"
|
||||||
|
|
||||||
|
def test_whitespace_only_returns_auto(self):
|
||||||
|
assert coerce_modal_mode(" ") == "auto"
|
||||||
|
|
||||||
|
def test_uppercase_normalized(self):
|
||||||
|
assert coerce_modal_mode("DIRECT") == "direct"
|
||||||
|
|
||||||
|
def test_mixed_case_normalized(self):
|
||||||
|
assert coerce_modal_mode("Managed") == "managed"
|
||||||
|
|
||||||
|
def test_invalid_mode_falls_back_to_auto(self):
|
||||||
|
assert coerce_modal_mode("invalid") == "auto"
|
||||||
|
assert coerce_modal_mode("cloud") == "auto"
|
||||||
|
|
||||||
|
def test_strips_whitespace(self):
|
||||||
|
assert coerce_modal_mode(" managed ") == "managed"
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeModalMode:
|
||||||
|
"""normalize_modal_mode is an alias for coerce_modal_mode."""
|
||||||
|
|
||||||
|
def test_delegates_to_coerce(self):
|
||||||
|
assert normalize_modal_mode("direct") == coerce_modal_mode("direct")
|
||||||
|
assert normalize_modal_mode(None) == coerce_modal_mode(None)
|
||||||
|
assert normalize_modal_mode("bogus") == coerce_modal_mode("bogus")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# has_direct_modal_credentials
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestHasDirectModalCredentials:
|
||||||
|
"""Detect Modal credentials via env vars or config file."""
|
||||||
|
|
||||||
|
def test_no_env_no_file(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
assert has_direct_modal_credentials() is False
|
||||||
|
|
||||||
|
def test_both_env_vars_set(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("MODAL_TOKEN_ID", "id-123")
|
||||||
|
monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456")
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
assert has_direct_modal_credentials() is True
|
||||||
|
|
||||||
|
def test_only_token_id_not_enough(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("MODAL_TOKEN_ID", "id-123")
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
assert has_direct_modal_credentials() is False
|
||||||
|
|
||||||
|
def test_only_token_secret_not_enough(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456")
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
assert has_direct_modal_credentials() is False
|
||||||
|
|
||||||
|
def test_config_file_present(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||||
|
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||||
|
(tmp_path / ".modal.toml").touch()
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
assert has_direct_modal_credentials() is True
|
||||||
|
|
||||||
|
def test_env_vars_take_priority_over_file(self, monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setenv("MODAL_TOKEN_ID", "id-123")
|
||||||
|
monkeypatch.setenv("MODAL_TOKEN_SECRET", "sec-456")
|
||||||
|
(tmp_path / ".modal.toml").touch()
|
||||||
|
with patch.object(Path, "home", return_value=tmp_path):
|
||||||
|
assert has_direct_modal_credentials() is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_modal_backend_state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestResolveModalBackendState:
|
||||||
|
"""Full matrix of direct vs managed Modal backend selection."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve(monkeypatch, mode, *, has_direct, managed_ready, nous_enabled=False):
|
||||||
|
"""Helper to call resolve_modal_backend_state with feature flag control."""
|
||||||
|
if nous_enabled:
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "1")
|
||||||
|
else:
|
||||||
|
monkeypatch.setenv("HERMES_ENABLE_NOUS_MANAGED_TOOLS", "")
|
||||||
|
return resolve_modal_backend_state(
|
||||||
|
mode, has_direct=has_direct, managed_ready=managed_ready
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- auto mode ---
|
||||||
|
|
||||||
|
def test_auto_prefers_managed_when_available(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=True)
|
||||||
|
assert result["selected_backend"] == "managed"
|
||||||
|
|
||||||
|
def test_auto_falls_back_to_direct(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False, nous_enabled=True)
|
||||||
|
assert result["selected_backend"] == "direct"
|
||||||
|
|
||||||
|
def test_auto_no_backends_available(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=False)
|
||||||
|
assert result["selected_backend"] is None
|
||||||
|
|
||||||
|
def test_auto_managed_ready_but_nous_disabled(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=True, nous_enabled=False)
|
||||||
|
assert result["selected_backend"] == "direct"
|
||||||
|
|
||||||
|
def test_auto_nothing_when_only_managed_and_nous_disabled(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "auto", has_direct=False, managed_ready=True, nous_enabled=False)
|
||||||
|
assert result["selected_backend"] is None
|
||||||
|
|
||||||
|
# --- direct mode ---
|
||||||
|
|
||||||
|
def test_direct_selects_direct_when_available(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=True, nous_enabled=True)
|
||||||
|
assert result["selected_backend"] == "direct"
|
||||||
|
|
||||||
|
def test_direct_none_when_no_credentials(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "direct", has_direct=False, managed_ready=True, nous_enabled=True)
|
||||||
|
assert result["selected_backend"] is None
|
||||||
|
|
||||||
|
# --- managed mode ---
|
||||||
|
|
||||||
|
def test_managed_selects_managed_when_ready_and_enabled(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=True)
|
||||||
|
assert result["selected_backend"] == "managed"
|
||||||
|
|
||||||
|
def test_managed_none_when_not_ready(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=False, nous_enabled=True)
|
||||||
|
assert result["selected_backend"] is None
|
||||||
|
|
||||||
|
def test_managed_blocked_when_nous_disabled(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "managed", has_direct=True, managed_ready=True, nous_enabled=False)
|
||||||
|
assert result["selected_backend"] is None
|
||||||
|
assert result["managed_mode_blocked"] is True
|
||||||
|
|
||||||
|
# --- return structure ---
|
||||||
|
|
||||||
|
def test_return_dict_keys(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "auto", has_direct=True, managed_ready=False)
|
||||||
|
expected_keys = {
|
||||||
|
"requested_mode",
|
||||||
|
"mode",
|
||||||
|
"has_direct",
|
||||||
|
"managed_ready",
|
||||||
|
"managed_mode_blocked",
|
||||||
|
"selected_backend",
|
||||||
|
}
|
||||||
|
assert set(result.keys()) == expected_keys
|
||||||
|
|
||||||
|
def test_passthrough_flags(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "direct", has_direct=True, managed_ready=False)
|
||||||
|
assert result["requested_mode"] == "direct"
|
||||||
|
assert result["mode"] == "direct"
|
||||||
|
assert result["has_direct"] is True
|
||||||
|
assert result["managed_ready"] is False
|
||||||
|
|
||||||
|
# --- invalid mode falls back to auto ---
|
||||||
|
|
||||||
|
def test_invalid_mode_treated_as_auto(self, monkeypatch):
|
||||||
|
result = self._resolve(monkeypatch, "bogus", has_direct=True, managed_ready=False)
|
||||||
|
assert result["requested_mode"] == "auto"
|
||||||
|
assert result["mode"] == "auto"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# resolve_openai_audio_api_key
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestResolveOpenaiAudioApiKey:
|
||||||
|
"""Priority: VOICE_TOOLS_OPENAI_KEY > OPENAI_API_KEY."""
|
||||||
|
|
||||||
|
def test_voice_key_preferred(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "voice-key")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "general-key")
|
||||||
|
assert resolve_openai_audio_api_key() == "voice-key"
|
||||||
|
|
||||||
|
def test_falls_back_to_openai_key(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "general-key")
|
||||||
|
assert resolve_openai_audio_api_key() == "general-key"
|
||||||
|
|
||||||
|
def test_empty_voice_key_falls_back(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "")
|
||||||
|
monkeypatch.setenv("OPENAI_API_KEY", "general-key")
|
||||||
|
assert resolve_openai_audio_api_key() == "general-key"
|
||||||
|
|
||||||
|
def test_no_keys_returns_empty(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
assert resolve_openai_audio_api_key() == ""
|
||||||
|
|
||||||
|
def test_strips_whitespace(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", " voice-key ")
|
||||||
|
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||||
|
assert resolve_openai_audio_api_key() == "voice-key"
|
||||||
@@ -16,7 +16,7 @@ This page is the top-level map of Hermes Agent internals. Use it to orient yours
|
|||||||
│ │
|
│ │
|
||||||
│ CLI (cli.py) Gateway (gateway/run.py) ACP (acp_adapter/) │
|
│ CLI (cli.py) Gateway (gateway/run.py) ACP (acp_adapter/) │
|
||||||
│ Batch Runner API Server Python Library │
|
│ Batch Runner API Server Python Library │
|
||||||
└──────────┬──────────────┬───────────────────────┬────────────────────┘
|
└──────────┬──────────────┬───────────────────────┬───────────────────┘
|
||||||
│ │ │
|
│ │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼
|
||||||
┌─────────────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ For the full voice feature set — including CLI microphone mode, spoken replies
|
|||||||
| Email | — | ✅ | ✅ | ✅ | — | — | — |
|
| Email | — | ✅ | ✅ | ✅ | — | — | — |
|
||||||
| Home Assistant | — | — | — | — | — | — | — |
|
| Home Assistant | — | — | — | — | — | — | — |
|
||||||
| Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
| Mattermost | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
||||||
| Matrix | ✅ | ✅ | ✅ | ✅ | — | ✅ | ✅ |
|
| Matrix | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| DingTalk | — | — | — | — | — | ✅ | ✅ |
|
| DingTalk | — | — | — | — | — | ✅ | ✅ |
|
||||||
| Feishu/Lark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
| Feishu/Lark | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||||
| WeCom | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
|
| WeCom | ✅ | ✅ | ✅ | — | — | ✅ | ✅ |
|
||||||
|
|||||||
Reference in New Issue
Block a user