Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31195b2f7f |
33
cli.py
33
cli.py
@@ -6852,12 +6852,11 @@ class HermesCLI:
|
||||
self._voice_stop_and_transcribe()
|
||||
|
||||
# Audio cue: single beep BEFORE starting stream (avoid CoreAudio conflict)
|
||||
if self._voice_beeps_enabled():
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=880, count=1)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=880, count=1)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self._voice_recorder.start(on_silence_stop=_on_silence)
|
||||
@@ -6905,12 +6904,11 @@ class HermesCLI:
|
||||
wav_path = self._voice_recorder.stop()
|
||||
|
||||
# Audio cue: double beep after stream stopped (no CoreAudio conflict)
|
||||
if self._voice_beeps_enabled():
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=660, count=2)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from tools.voice_mode import play_beep
|
||||
play_beep(frequency=660, count=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if wav_path is None:
|
||||
_cprint(f"{_DIM}No speech detected.{_RST}")
|
||||
@@ -7061,17 +7059,6 @@ class HermesCLI:
|
||||
_cprint(f"Unknown voice subcommand: {subcommand}")
|
||||
_cprint("Usage: /voice [on|off|tts|status]")
|
||||
|
||||
def _voice_beeps_enabled(self) -> bool:
|
||||
"""Return whether CLI voice mode should play record start/stop beeps."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
voice_cfg = load_config().get("voice", {})
|
||||
if isinstance(voice_cfg, dict):
|
||||
return bool(voice_cfg.get("beep_enabled", True))
|
||||
except Exception:
|
||||
pass
|
||||
return True
|
||||
|
||||
def _enable_voice_mode(self):
|
||||
"""Enable voice mode after checking requirements."""
|
||||
if self._voice_mode:
|
||||
|
||||
@@ -250,12 +250,16 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"big-pickle",
|
||||
],
|
||||
"opencode-go": [
|
||||
"glm-5",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"qwen3.6-plus",
|
||||
"qwen3.5-plus",
|
||||
],
|
||||
"ai-gateway": [
|
||||
"anthropic/claude-opus-4.6",
|
||||
|
||||
@@ -105,7 +105,7 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"ai-gateway": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5", "google/gemini-3-flash"],
|
||||
"kilocode": ["anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
|
||||
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
|
||||
"opencode-go": ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.5", "minimax-m2.7"],
|
||||
"opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.5", "minimax-m2.7", "qwen3.6-plus", "qwen3.5-plus"],
|
||||
"huggingface": [
|
||||
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
|
||||
|
||||
@@ -4,32 +4,62 @@ import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import list_authenticated_providers
|
||||
from hermes_cli.models import curated_models_for_provider
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"OPENCODE_GO_API_KEY": "test-key"}, clear=False)
|
||||
def test_opencode_go_appears_when_api_key_set():
|
||||
"""opencode-go should appear in list_authenticated_providers when OPENCODE_GO_API_KEY is set."""
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
|
||||
|
||||
# Find opencode-go in results
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
|
||||
|
||||
assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set"
|
||||
assert opencode_go["models"] == ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"]
|
||||
assert opencode_go["models"] == [
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
]
|
||||
assert opencode_go["total_models"] == 10
|
||||
# opencode-go can appear as "built-in" (from PROVIDER_TO_MODELS_DEV when
|
||||
# models.dev is reachable) or "hermes" (from HERMES_OVERLAYS fallback when
|
||||
# the API is unavailable, e.g. in CI).
|
||||
assert opencode_go["source"] in ("built-in", "hermes")
|
||||
|
||||
|
||||
@patch("hermes_cli.models.provider_model_ids", return_value=[])
|
||||
def test_opencode_go_curated_fallback_includes_new_models(_mock_provider_model_ids):
|
||||
"""Fallback catalog should include Kimi K2.6 and both Qwen Plus models."""
|
||||
model_ids = [model_id for model_id, _ in curated_models_for_provider("opencode-go")]
|
||||
|
||||
assert model_ids == [
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"glm-5.1",
|
||||
"glm-5",
|
||||
"mimo-v2-pro",
|
||||
"mimo-v2-omni",
|
||||
"minimax-m2.7",
|
||||
"minimax-m2.5",
|
||||
"qwen3.6-plus",
|
||||
"qwen3.5-plus",
|
||||
]
|
||||
|
||||
|
||||
def test_opencode_go_not_appears_when_no_creds():
|
||||
"""opencode-go should NOT appear when no credentials are set."""
|
||||
# Ensure OPENCODE_GO_API_KEY is not set
|
||||
env_without_key = {k: v for k, v in os.environ.items() if k != "OPENCODE_GO_API_KEY"}
|
||||
|
||||
|
||||
with patch.dict(os.environ, env_without_key, clear=True):
|
||||
providers = list_authenticated_providers(current_provider="openrouter")
|
||||
|
||||
|
||||
# opencode-go should not be in results
|
||||
opencode_go = next((p for p in providers if p["slug"] == "opencode-go"), None)
|
||||
assert opencode_go is None, "opencode-go should not appear without credentials"
|
||||
|
||||
@@ -4,31 +4,13 @@ state management, streaming TTS activation, voice message prefix, _vprint."""
|
||||
import ast
|
||||
import os
|
||||
import queue
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _ensure_cli_import_shims():
|
||||
sys.modules.setdefault(
|
||||
"agent.auxiliary_client",
|
||||
types.SimpleNamespace(
|
||||
call_llm=lambda *args, **kwargs: "",
|
||||
async_call_llm=lambda *args, **kwargs: "",
|
||||
extract_content_or_reasoning=lambda *args, **kwargs: "",
|
||||
resolve_provider_client=lambda *args, **kwargs: (None, None, None, None),
|
||||
get_async_text_auxiliary_client=lambda *args, **kwargs: None,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_ensure_cli_import_shims()
|
||||
|
||||
|
||||
def _make_voice_cli(**overrides):
|
||||
"""Create a minimal HermesCLI with only voice-related attrs initialized.
|
||||
|
||||
@@ -36,7 +18,6 @@ def _make_voice_cli(**overrides):
|
||||
needed. Only the voice state attributes (from __init__ lines 3749-3758)
|
||||
are populated.
|
||||
"""
|
||||
_ensure_cli_import_shims()
|
||||
from cli import HermesCLI
|
||||
|
||||
cli = HermesCLI.__new__(HermesCLI)
|
||||
@@ -952,58 +933,6 @@ class TestEnableVoiceModeReal:
|
||||
assert cli._voice_mode is True
|
||||
|
||||
|
||||
class TestVoiceBeepConfigReal:
|
||||
"""Tests the CLI voice beep toggle."""
|
||||
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {}})
|
||||
def test_beeps_enabled_by_default(self, _cfg):
|
||||
cli = _make_voice_cli()
|
||||
assert cli._voice_beeps_enabled() is True
|
||||
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
|
||||
def test_beeps_can_be_disabled(self, _cfg):
|
||||
cli = _make_voice_cli()
|
||||
assert cli._voice_beeps_enabled() is False
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.threading.Thread")
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
@patch("tools.voice_mode.create_audio_recorder")
|
||||
@patch(
|
||||
"tools.voice_mode.check_voice_requirements",
|
||||
return_value={
|
||||
"available": True,
|
||||
"audio_available": True,
|
||||
"stt_available": True,
|
||||
"details": "OK",
|
||||
"missing_packages": [],
|
||||
},
|
||||
)
|
||||
@patch(
|
||||
"hermes_cli.config.load_config",
|
||||
return_value={
|
||||
"voice": {
|
||||
"beep_enabled": False,
|
||||
"silence_threshold": 200,
|
||||
"silence_duration": 3.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
def test_start_recording_skips_beep_when_disabled(
|
||||
self, _cfg, _req, mock_create, mock_beep, mock_thread, _cp
|
||||
):
|
||||
recorder = MagicMock()
|
||||
recorder.supports_silence_autostop = True
|
||||
mock_create.return_value = recorder
|
||||
mock_thread.return_value = MagicMock(start=MagicMock())
|
||||
|
||||
cli = _make_voice_cli()
|
||||
cli._voice_start_recording()
|
||||
|
||||
recorder.start.assert_called_once()
|
||||
mock_beep.assert_not_called()
|
||||
|
||||
|
||||
class TestDisableVoiceModeReal:
|
||||
"""Tests _disable_voice_mode with real CLI instance."""
|
||||
|
||||
@@ -1158,16 +1087,6 @@ class TestVoiceStopAndTranscribeReal:
|
||||
cli._voice_stop_and_transcribe()
|
||||
assert cli._pending_input.empty()
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("hermes_cli.config.load_config", return_value={"voice": {"beep_enabled": False}})
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
def test_no_speech_detected_skips_beep_when_disabled(self, mock_beep, _cfg, _cp):
|
||||
recorder = MagicMock()
|
||||
recorder.stop.return_value = None
|
||||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder)
|
||||
cli._voice_stop_and_transcribe()
|
||||
mock_beep.assert_not_called()
|
||||
|
||||
@patch("cli._cprint")
|
||||
@patch("cli.os.unlink")
|
||||
@patch("cli.os.path.isfile", return_value=True)
|
||||
@@ -1237,18 +1156,12 @@ class TestVoiceStopAndTranscribeReal:
|
||||
@patch("cli._cprint")
|
||||
@patch("tools.voice_mode.play_beep")
|
||||
def test_continuous_restarts_on_no_speech(self, _beep, _cp):
|
||||
import time
|
||||
|
||||
recorder = MagicMock()
|
||||
recorder.stop.return_value = None
|
||||
cli = _make_voice_cli(_voice_recording=True, _voice_recorder=recorder,
|
||||
_voice_continuous=True)
|
||||
cli._voice_start_recording = MagicMock()
|
||||
cli._voice_stop_and_transcribe()
|
||||
for _ in range(50):
|
||||
if cli._voice_start_recording.call_count:
|
||||
break
|
||||
time.sleep(0.01)
|
||||
cli._voice_start_recording.assert_called_once()
|
||||
|
||||
@patch("cli._cprint")
|
||||
|
||||
@@ -149,7 +149,7 @@ Two-stage algorithm detects when you've finished speaking:
|
||||
|
||||
If no speech is detected at all for 15 seconds, recording stops automatically.
|
||||
|
||||
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`. You can also disable the record start/stop beeps with `voice.beep_enabled: false`.
|
||||
Both `silence_threshold` and `silence_duration` are configurable in `config.yaml`.
|
||||
|
||||
### Streaming TTS
|
||||
|
||||
@@ -383,7 +383,6 @@ voice:
|
||||
record_key: "ctrl+b" # Key to start/stop recording
|
||||
max_recording_seconds: 120 # Maximum recording length
|
||||
auto_tts: false # Auto-enable TTS when voice mode starts
|
||||
beep_enabled: true # Play record start/stop beeps
|
||||
silence_threshold: 200 # RMS level (0-32767) below which counts as silence
|
||||
silence_duration: 3.0 # Seconds of silence before auto-stop
|
||||
|
||||
|
||||
Reference in New Issue
Block a user