python-voiceio 0.5.0__tar.gz → 0.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_voiceio-0.5.0/python_voiceio.egg-info → python_voiceio-0.7.0}/PKG-INFO +1 -1
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/pyproject.toml +1 -1
- {python_voiceio-0.5.0 → python_voiceio-0.7.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- python_voiceio-0.7.0/tests/test_llm_api.py +261 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_postcorrect.py +116 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_retention.py +69 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_streaming.py +301 -2
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_transcriber.py +36 -0
- python_voiceio-0.7.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/app.py +11 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/config.py +25 -1
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/llm_api.py +60 -11
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/postcorrect.py +75 -6
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/retention.py +65 -1
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/streaming.py +161 -12
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/transcriber.py +22 -6
- python_voiceio-0.5.0/tests/test_llm_api.py +0 -220
- python_voiceio-0.5.0/voiceio/__init__.py +0 -1
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/LICENSE +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/README.md +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/setup.cfg +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_config.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_health.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_history.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_robustness.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_wizard.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/audit.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/cli.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/health.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/history.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/recorder.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/service.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/wizard.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/worker.py +0 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Tests for voiceio.llm_api — OpenAI-compatible chat completions client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import http.client
|
|
5
|
+
import json
|
|
6
|
+
import urllib.error
|
|
7
|
+
from unittest.mock import MagicMock, patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from voiceio import llm_api
|
|
12
|
+
from voiceio.config import AutocorrectConfig
|
|
13
|
+
from voiceio.llm_api import chat, check_api_key, detect_provider, resolve_api_key
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def _clear_pool():
|
|
18
|
+
"""Keep-alive pool is process-global state — isolate every test."""
|
|
19
|
+
llm_api._CONN_POOL.clear()
|
|
20
|
+
yield
|
|
21
|
+
llm_api._CONN_POOL.clear()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _fake_conn(data: dict | None, status: int = 200) -> MagicMock:
|
|
25
|
+
"""A mock http.client connection returning one JSON response."""
|
|
26
|
+
resp = MagicMock()
|
|
27
|
+
resp.status = status
|
|
28
|
+
resp.reason = "OK" if status == 200 else "ERR"
|
|
29
|
+
resp.read.return_value = json.dumps(data or {}).encode()
|
|
30
|
+
resp.getheaders.return_value = []
|
|
31
|
+
conn = MagicMock()
|
|
32
|
+
conn.getresponse.return_value = resp
|
|
33
|
+
return conn
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _request_kwargs(conn: MagicMock) -> tuple[str, dict, dict]:
|
|
37
|
+
"""(path, headers, parsed body) of the single request made on conn."""
|
|
38
|
+
args, kwargs = conn.request.call_args
|
|
39
|
+
return args[1], kwargs["headers"], json.loads(kwargs["body"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _cfg(**kwargs) -> AutocorrectConfig:
|
|
43
|
+
defaults = {"api_key": "test-key", "model": "test-model"}
|
|
44
|
+
defaults.update(kwargs)
|
|
45
|
+
return AutocorrectConfig(**defaults)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ── resolve_api_key ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_resolve_from_config():
|
|
52
|
+
cfg = _cfg(api_key="sk-from-config")
|
|
53
|
+
assert resolve_api_key(cfg) == "sk-from-config"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@patch.dict("os.environ", {"OPENROUTER_API_KEY": "sk-from-env"}, clear=False)
|
|
57
|
+
def test_resolve_from_env():
|
|
58
|
+
cfg = _cfg(api_key="")
|
|
59
|
+
assert resolve_api_key(cfg) == "sk-from-env"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@patch.dict("os.environ", {}, clear=True)
|
|
63
|
+
def test_resolve_empty():
|
|
64
|
+
cfg = _cfg(api_key="")
|
|
65
|
+
assert resolve_api_key(cfg) == ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ── chat ─────────────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_chat_success():
|
|
72
|
+
conn = _fake_conn({"choices": [{"message": {"content": "Fixed text."}}]})
|
|
73
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
74
|
+
result = chat(_cfg(), "system prompt", "user message")
|
|
75
|
+
assert result == "Fixed text."
|
|
76
|
+
|
|
77
|
+
path, headers, body = _request_kwargs(conn)
|
|
78
|
+
assert path == "/v1/chat/completions" or path.endswith("/chat/completions")
|
|
79
|
+
assert headers["Authorization"] == "Bearer test-key"
|
|
80
|
+
assert headers["Content-Type"] == "application/json"
|
|
81
|
+
assert body["model"] == "test-model"
|
|
82
|
+
assert body["messages"][0]["role"] == "system"
|
|
83
|
+
assert body["messages"][1]["role"] == "user"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_chat_timeout():
|
|
87
|
+
conn = MagicMock()
|
|
88
|
+
conn.request.side_effect = OSError("timed out")
|
|
89
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
90
|
+
assert chat(_cfg(), "sys", "msg") is None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_chat_http_401():
|
|
94
|
+
conn = _fake_conn({"error": "invalid key"}, status=401)
|
|
95
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
96
|
+
assert chat(_cfg(), "sys", "msg") is None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_chat_no_api_key():
|
|
100
|
+
cfg = _cfg(api_key="")
|
|
101
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
102
|
+
assert chat(cfg, "sys", "msg") is None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_chat_malformed_response():
|
|
106
|
+
conn = _fake_conn({"unexpected": "format"})
|
|
107
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
108
|
+
assert chat(_cfg(), "sys", "msg") is None
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_chat_null_content_returns_none():
|
|
112
|
+
"""Thinking models (Kimi K2.6 etc.) can return content=None — must not crash."""
|
|
113
|
+
conn = _fake_conn({"choices": [{"message": {"content": None}}]})
|
|
114
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
115
|
+
assert chat(_cfg(), "sys", "msg") is None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_chat_falls_back_to_reasoning_field():
|
|
119
|
+
"""When content is null but reasoning is present, use reasoning text."""
|
|
120
|
+
conn = _fake_conn({
|
|
121
|
+
"choices": [{"message": {"content": None, "reasoning": "the answer"}}],
|
|
122
|
+
})
|
|
123
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
124
|
+
assert chat(_cfg(), "sys", "msg") == "the answer"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_chat_falls_back_to_reasoning_content_field():
|
|
128
|
+
"""Some providers expose `reasoning_content` instead of `reasoning`."""
|
|
129
|
+
conn = _fake_conn({
|
|
130
|
+
"choices": [{"message": {
|
|
131
|
+
"content": None,
|
|
132
|
+
"reasoning_content": "thought-through answer",
|
|
133
|
+
}}],
|
|
134
|
+
})
|
|
135
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
136
|
+
assert chat(_cfg(), "sys", "msg") == "thought-through answer"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_chat_anthropic_null_content_array():
|
|
140
|
+
"""Anthropic native API: content=null shouldn't crash."""
|
|
141
|
+
conn = _fake_conn({"content": None})
|
|
142
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
143
|
+
cfg = _cfg(base_url="https://api.anthropic.com/v1")
|
|
144
|
+
assert chat(cfg, "sys", "msg") is None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── check_api_key ────────────────────────────────────────────────────────
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_check_valid_key():
|
|
151
|
+
conn = _fake_conn({"choices": [{"message": {"content": ""}}]})
|
|
152
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
153
|
+
assert check_api_key(_cfg(), "sk-valid") is True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_check_invalid_key():
|
|
157
|
+
conn = _fake_conn({"error": "unauthorized"}, status=401)
|
|
158
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
159
|
+
assert check_api_key(_cfg(), "sk-invalid") is False
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_check_empty_key():
|
|
163
|
+
cfg = _cfg(api_key="")
|
|
164
|
+
with patch.dict("os.environ", {}, clear=True):
|
|
165
|
+
assert check_api_key(cfg) is False
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
# ── Anthropic native API ────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def test_chat_anthropic_native():
|
|
172
|
+
conn = _fake_conn({"content": [{"type": "text", "text": "Fixed text."}]})
|
|
173
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
174
|
+
cfg = _cfg(base_url="https://api.anthropic.com/v1")
|
|
175
|
+
result = chat(cfg, "system prompt", "user message")
|
|
176
|
+
assert result == "Fixed text."
|
|
177
|
+
|
|
178
|
+
path, headers, body = _request_kwargs(conn)
|
|
179
|
+
assert path.endswith("/messages")
|
|
180
|
+
assert headers["x-api-key"] == "test-key"
|
|
181
|
+
assert headers["anthropic-version"] == "2023-06-01"
|
|
182
|
+
assert "Authorization" not in headers
|
|
183
|
+
assert body["system"] == "system prompt"
|
|
184
|
+
assert body["messages"] == [{"role": "user", "content": "user message"}]
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def test_check_api_key_anthropic():
|
|
188
|
+
conn = _fake_conn({"content": [{"type": "text", "text": ""}]})
|
|
189
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
190
|
+
cfg = _cfg(base_url="https://api.anthropic.com/v1")
|
|
191
|
+
assert check_api_key(cfg, "sk-ant-test") is True
|
|
192
|
+
path, _, _ = _request_kwargs(conn)
|
|
193
|
+
assert path.endswith("/messages")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── detect_provider ─────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def test_detect_openrouter():
|
|
200
|
+
base_url, model = detect_provider("sk-or-abc123")
|
|
201
|
+
assert "openrouter" in base_url
|
|
202
|
+
assert "kimi" in model
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_detect_anthropic():
|
|
206
|
+
base_url, model = detect_provider("sk-ant-abc123")
|
|
207
|
+
assert "anthropic.com" in base_url
|
|
208
|
+
assert "claude" in model
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_detect_openai():
|
|
212
|
+
base_url, model = detect_provider("sk-proj-abc123")
|
|
213
|
+
assert "openai.com" in base_url
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def test_detect_unknown_defaults_openrouter():
|
|
217
|
+
base_url, _ = detect_provider("unknown-key-format")
|
|
218
|
+
assert "openrouter" in base_url
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── keep-alive connection pool ───────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def test_post_json_reuses_connection():
|
|
225
|
+
conn = _fake_conn({"ok": 1})
|
|
226
|
+
with patch("http.client.HTTPSConnection", return_value=conn) as cls:
|
|
227
|
+
r1 = llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
|
|
228
|
+
r2 = llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
|
|
229
|
+
assert r1 == {"ok": 1} and r2 == {"ok": 1}
|
|
230
|
+
cls.assert_called_once() # second call reused the pooled connection
|
|
231
|
+
assert conn.request.call_count == 2
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def test_post_json_retries_stale_pooled_connection():
|
|
235
|
+
stale = MagicMock()
|
|
236
|
+
stale.sock = None
|
|
237
|
+
stale.request.side_effect = http.client.BadStatusLine("") # server closed it
|
|
238
|
+
llm_api._CONN_POOL["https://api.example.com"] = stale
|
|
239
|
+
fresh = _fake_conn({"ok": 2})
|
|
240
|
+
with patch("http.client.HTTPSConnection", return_value=fresh):
|
|
241
|
+
r = llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
|
|
242
|
+
assert r == {"ok": 2}
|
|
243
|
+
stale.close.assert_called_once()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_post_json_fresh_connection_error_propagates():
|
|
247
|
+
conn = MagicMock()
|
|
248
|
+
conn.request.side_effect = OSError("connection refused")
|
|
249
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
250
|
+
with pytest.raises(OSError):
|
|
251
|
+
llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_post_json_http_error_compatible():
|
|
255
|
+
conn = _fake_conn({"error": "bad key"}, status=401)
|
|
256
|
+
with patch("http.client.HTTPSConnection", return_value=conn):
|
|
257
|
+
with pytest.raises(urllib.error.HTTPError) as exc:
|
|
258
|
+
llm_api._post_json("https://api.example.com/v1/x", {}, {}, 5)
|
|
259
|
+
assert exc.value.code == 401
|
|
260
|
+
assert b"bad key" in exc.value.read()
|
|
261
|
+
assert not llm_api._CONN_POOL # error connections are not pooled
|
|
@@ -161,3 +161,119 @@ def test_pipeline_runs_postcorrect_only_on_final():
|
|
|
161
161
|
)
|
|
162
162
|
assert text == "Crisp chat is the best tool"
|
|
163
163
|
assert not abort
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── wall-clock deadline ──────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_deadline_exceeded_keeps_original():
|
|
170
|
+
"""A chat call slower than timeout_secs is abandoned at the deadline."""
|
|
171
|
+
import time as _time
|
|
172
|
+
cfg = _cfg()
|
|
173
|
+
cfg.postcorrect.timeout_secs = 0.2
|
|
174
|
+
|
|
175
|
+
def slow_chat(*a, **kw):
|
|
176
|
+
_time.sleep(2.0)
|
|
177
|
+
return "Crisp chat is the tool"
|
|
178
|
+
|
|
179
|
+
pc = PostCorrector(cfg)
|
|
180
|
+
with patch("voiceio.llm_api.chat", side_effect=slow_chat):
|
|
181
|
+
t0 = _time.monotonic()
|
|
182
|
+
assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
|
|
183
|
+
elapsed = _time.monotonic() - t0
|
|
184
|
+
assert elapsed < 1.0 # returned at the deadline, not after the full call
|
|
185
|
+
assert pc.last_secs is not None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_within_deadline_applies_correction():
|
|
189
|
+
cfg = _cfg()
|
|
190
|
+
cfg.postcorrect.timeout_secs = 5.0
|
|
191
|
+
pc = PostCorrector(cfg)
|
|
192
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
193
|
+
assert pc.correct("Chris chat is the tool") == "Crisp chat is the tool"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ── before/after pair persistence ────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _read_pairs(path):
|
|
200
|
+
import json
|
|
201
|
+
return [json.loads(line) for line in path.read_text().splitlines()]
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_applied_correction_recorded(tmp_path, monkeypatch):
|
|
205
|
+
import voiceio.postcorrect as pcmod
|
|
206
|
+
from voiceio import config as cfgmod
|
|
207
|
+
pairs = tmp_path / "pairs.jsonl"
|
|
208
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
|
|
209
|
+
pc = PostCorrector(_cfg())
|
|
210
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
211
|
+
pc.correct("Chris chat is the tool")
|
|
212
|
+
entries = _read_pairs(pairs)
|
|
213
|
+
assert len(entries) == 1
|
|
214
|
+
assert entries[0]["outcome"] == "applied"
|
|
215
|
+
assert entries[0]["before"] == "Chris chat is the tool"
|
|
216
|
+
assert entries[0]["after"] == "Crisp chat is the tool"
|
|
217
|
+
assert entries[0]["secs"] is not None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_rejected_rewrite_recorded(tmp_path, monkeypatch):
|
|
221
|
+
from voiceio import config as cfgmod
|
|
222
|
+
pairs = tmp_path / "pairs.jsonl"
|
|
223
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
|
|
224
|
+
pc = PostCorrector(_cfg())
|
|
225
|
+
with patch("voiceio.llm_api.chat", return_value="Completely different rewritten sentence with many other words entirely"):
|
|
226
|
+
out = pc.correct("Chris chat is the tool")
|
|
227
|
+
assert out == "Chris chat is the tool"
|
|
228
|
+
entries = _read_pairs(pairs)
|
|
229
|
+
assert len(entries) == 1
|
|
230
|
+
assert entries[0]["outcome"].startswith("rejected")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_no_recording_when_disabled(tmp_path, monkeypatch):
|
|
234
|
+
from voiceio import config as cfgmod
|
|
235
|
+
pairs = tmp_path / "pairs.jsonl"
|
|
236
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", pairs)
|
|
237
|
+
cfg = _cfg()
|
|
238
|
+
cfg.data.capture_intermediates = False
|
|
239
|
+
pc = PostCorrector(cfg)
|
|
240
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
241
|
+
pc.correct("Chris chat is the tool")
|
|
242
|
+
assert not pairs.exists()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def test_hung_request_blocks_next_call_not_thread_pileup(tmp_path, monkeypatch):
|
|
246
|
+
"""A worker abandoned at the deadline makes the NEXT call skip the LLM."""
|
|
247
|
+
import threading as _threading
|
|
248
|
+
import time as _time
|
|
249
|
+
from voiceio import config as cfgmod
|
|
250
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", tmp_path / "pairs.jsonl")
|
|
251
|
+
cfg = _cfg()
|
|
252
|
+
cfg.postcorrect.timeout_secs = 0.1
|
|
253
|
+
pc = PostCorrector(cfg)
|
|
254
|
+
release = _threading.Event()
|
|
255
|
+
|
|
256
|
+
def hung_chat(*a, **kw):
|
|
257
|
+
release.wait(5)
|
|
258
|
+
return "x"
|
|
259
|
+
|
|
260
|
+
with patch("voiceio.llm_api.chat", side_effect=hung_chat) as mock_chat:
|
|
261
|
+
assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
|
|
262
|
+
assert mock_chat.call_count == 1
|
|
263
|
+
# Second call while the first worker is still hung: no new thread
|
|
264
|
+
assert pc.correct("Chris chat is the tool") == "Chris chat is the tool"
|
|
265
|
+
assert mock_chat.call_count == 1
|
|
266
|
+
release.set()
|
|
267
|
+
entries = _read_pairs(tmp_path / "pairs.jsonl")
|
|
268
|
+
assert [e["outcome"] for e in entries] == ["timeout", "skipped_busy"]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def test_recorded_context_is_effective_context(tmp_path, monkeypatch):
|
|
272
|
+
from voiceio import config as cfgmod
|
|
273
|
+
monkeypatch.setattr(cfgmod, "POSTCORRECT_PAIRS_PATH", tmp_path / "pairs.jsonl")
|
|
274
|
+
pc = PostCorrector(_cfg())
|
|
275
|
+
pc.set_context(title="Stale Window")
|
|
276
|
+
with patch("voiceio.llm_api.chat", return_value="Crisp chat is the tool"):
|
|
277
|
+
pc.correct("Chris chat is the tool", context="Fresh Window")
|
|
278
|
+
entries = _read_pairs(tmp_path / "pairs.jsonl")
|
|
279
|
+
assert entries[0]["context"] == "Fresh Window"
|
|
@@ -97,3 +97,72 @@ class TestHistoryExtra:
|
|
|
97
97
|
entry = json.loads(p.read_text())
|
|
98
98
|
assert entry["text"] == "hello"
|
|
99
99
|
assert entry["ts"] != 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ── intermediate data capture + disk floor ──────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class TestAppendJsonl:
|
|
106
|
+
def test_appends_lines(self, tmp_path):
|
|
107
|
+
from voiceio import retention
|
|
108
|
+
p = tmp_path / "pairs.jsonl"
|
|
109
|
+
retention.append_jsonl(p, {"a": 1})
|
|
110
|
+
retention.append_jsonl(p, {"b": 2})
|
|
111
|
+
import json
|
|
112
|
+
lines = [json.loads(line) for line in p.read_text().splitlines()]
|
|
113
|
+
assert lines == [{"a": 1}, {"b": 2}]
|
|
114
|
+
|
|
115
|
+
def test_never_raises_on_bad_entry(self, tmp_path):
|
|
116
|
+
from voiceio import retention
|
|
117
|
+
retention.append_jsonl(tmp_path / "x.jsonl", {"bad": object()}) # not JSON-able
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class TestSaveTrace:
|
|
121
|
+
def test_writes_when_enabled(self, tmp_path, monkeypatch):
|
|
122
|
+
from voiceio import retention
|
|
123
|
+
from voiceio.config import DataConfig
|
|
124
|
+
monkeypatch.setattr(retention, "TRACES_PATH", tmp_path / "trace.jsonl")
|
|
125
|
+
retention.save_trace(DataConfig(), {"ts": 1, "passes": []})
|
|
126
|
+
assert (tmp_path / "trace.jsonl").exists()
|
|
127
|
+
|
|
128
|
+
def test_skips_when_disabled(self, tmp_path, monkeypatch):
|
|
129
|
+
from voiceio import retention
|
|
130
|
+
from voiceio.config import DataConfig
|
|
131
|
+
monkeypatch.setattr(retention, "TRACES_PATH", tmp_path / "trace.jsonl")
|
|
132
|
+
retention.save_trace(DataConfig(capture_intermediates=False), {"ts": 1})
|
|
133
|
+
assert not (tmp_path / "trace.jsonl").exists()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TestDiskFloor:
|
|
137
|
+
def test_save_audio_skips_below_floor(self, monkeypatch):
|
|
138
|
+
import numpy as np
|
|
139
|
+
from voiceio import retention
|
|
140
|
+
from voiceio.config import DataConfig
|
|
141
|
+
monkeypatch.setattr(retention, "_free_gb", lambda p: 1.0)
|
|
142
|
+
name = retention.save_audio(
|
|
143
|
+
np.zeros(1600, dtype=np.float32), 0.0, DataConfig(min_free_gb=5.0),
|
|
144
|
+
)
|
|
145
|
+
assert name is None
|
|
146
|
+
|
|
147
|
+
def test_save_audio_ok_above_floor(self, tmp_path, monkeypatch):
|
|
148
|
+
import numpy as np
|
|
149
|
+
from voiceio import retention
|
|
150
|
+
from voiceio.config import DataConfig
|
|
151
|
+
monkeypatch.setattr(retention, "RECORDINGS_DIR", tmp_path)
|
|
152
|
+
monkeypatch.setattr(retention, "_free_gb", lambda p: 100.0)
|
|
153
|
+
name = retention.save_audio(
|
|
154
|
+
np.zeros(1600, dtype=np.float32), 0.0, DataConfig(min_free_gb=5.0),
|
|
155
|
+
)
|
|
156
|
+
assert name is not None and (tmp_path / name).exists()
|
|
157
|
+
|
|
158
|
+
def test_prune_shrinks_budget_below_floor(self, tmp_path, monkeypatch):
|
|
159
|
+
from voiceio import retention
|
|
160
|
+
from voiceio.config import DataConfig
|
|
161
|
+
monkeypatch.setattr(retention, "RECORDINGS_DIR", tmp_path)
|
|
162
|
+
monkeypatch.setattr(retention, "_free_gb", lambda p: 1.0)
|
|
163
|
+
for i in range(4):
|
|
164
|
+
(tmp_path / f"r{i}.wav").write_bytes(b"x" * 1000)
|
|
165
|
+
retention.prune(DataConfig(max_audio_mb=1024, min_free_gb=5.0))
|
|
166
|
+
# Budget halved from 4000 bytes total → keep <= 2000 bytes
|
|
167
|
+
remaining = sum(p.stat().st_size for p in tmp_path.glob("*.wav"))
|
|
168
|
+
assert remaining <= 2000
|