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.
Files changed (125) hide show
  1. {python_voiceio-0.5.0/python_voiceio.egg-info → python_voiceio-0.7.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.5.0 → python_voiceio-0.7.0/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. python_voiceio-0.7.0/tests/test_llm_api.py +261 -0
  5. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_postcorrect.py +116 -0
  6. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_retention.py +69 -0
  7. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_streaming.py +301 -2
  8. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_transcriber.py +36 -0
  9. python_voiceio-0.7.0/voiceio/__init__.py +1 -0
  10. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/app.py +11 -0
  11. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/config.py +25 -1
  12. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/llm_api.py +60 -11
  13. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/postcorrect.py +75 -6
  14. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/retention.py +65 -1
  15. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/streaming.py +161 -12
  16. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/transcriber.py +22 -6
  17. python_voiceio-0.5.0/tests/test_llm_api.py +0 -220
  18. python_voiceio-0.5.0/voiceio/__init__.py +0 -1
  19. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/LICENSE +0 -0
  20. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/README.md +0 -0
  21. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  22. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  23. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  24. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/requires.txt +0 -0
  25. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/top_level.txt +0 -0
  26. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/setup.cfg +0 -0
  27. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_adjudicate.py +0 -0
  28. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_app_wiring.py +0 -0
  29. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_audio_quality.py +0 -0
  30. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_audit.py +0 -0
  31. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_autocorrect.py +0 -0
  32. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_autocorrect_state.py +0 -0
  33. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_backend_probes.py +0 -0
  34. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_cli.py +0 -0
  35. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_clipboard_read.py +0 -0
  36. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_commands.py +0 -0
  37. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_concurrency_lockdown.py +0 -0
  38. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_config.py +0 -0
  39. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_correct_batch.py +0 -0
  40. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_corrections.py +0 -0
  41. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_fallback.py +0 -0
  42. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_health.py +0 -0
  43. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_hints.py +0 -0
  44. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_history.py +0 -0
  45. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_ibus_pending.py +0 -0
  46. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_ibus_typer.py +0 -0
  47. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_llm.py +0 -0
  48. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_numbers.py +0 -0
  49. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_platform.py +0 -0
  50. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_postprocess.py +0 -0
  51. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_prebuffer.py +0 -0
  52. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_prompt.py +0 -0
  53. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_recorder_integration.py +0 -0
  54. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_robustness.py +0 -0
  55. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_security_hardening.py +0 -0
  56. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_snapshots.py +0 -0
  57. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_tts.py +0 -0
  58. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_vad.py +0 -0
  59. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_vocabulary.py +0 -0
  60. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_wizard.py +0 -0
  61. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/tests/test_wordfreq.py +0 -0
  62. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/__main__.py +0 -0
  63. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/audit.py +0 -0
  64. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/autocorrect.py +0 -0
  65. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/autocorrect_state.py +0 -0
  66. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/backends.py +0 -0
  67. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/cli.py +0 -0
  68. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/clipboard_read.py +0 -0
  69. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/commands.py +0 -0
  70. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/consent.py +0 -0
  71. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/corrections.py +0 -0
  72. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/demo.py +0 -0
  73. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/feedback.py +0 -0
  74. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/health.py +0 -0
  75. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hints.py +0 -0
  76. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/history.py +0 -0
  77. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/__init__.py +0 -0
  78. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/base.py +0 -0
  79. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/chain.py +0 -0
  80. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/evdev.py +0 -0
  81. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  82. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/hotkeys/socket_backend.py +0 -0
  83. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/ibus/__init__.py +0 -0
  84. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/ibus/engine.py +0 -0
  85. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/ibus/pending.py +0 -0
  86. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/llm.py +0 -0
  87. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/models/__init__.py +0 -0
  88. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/models/silero_vad.onnx +0 -0
  89. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/numbers.py +0 -0
  90. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/pidlock.py +0 -0
  91. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/platform.py +0 -0
  92. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/postprocess.py +0 -0
  93. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/prompt.py +0 -0
  94. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/recorder.py +0 -0
  95. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/service.py +0 -0
  96. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/snapshots.py +0 -0
  97. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/__init__.py +0 -0
  98. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/commit.wav +0 -0
  99. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/start.wav +0 -0
  100. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/sounds/stop.wav +0 -0
  101. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/__init__.py +0 -0
  102. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/_icons.py +0 -0
  103. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/_indicator.py +0 -0
  104. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tray/_pystray.py +0 -0
  105. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/__init__.py +0 -0
  106. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/base.py +0 -0
  107. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/chain.py +0 -0
  108. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/edge_engine.py +0 -0
  109. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/espeak.py +0 -0
  110. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/piper_engine.py +0 -0
  111. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/tts/player.py +0 -0
  112. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/__init__.py +0 -0
  113. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/base.py +0 -0
  114. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/chain.py +0 -0
  115. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/clipboard.py +0 -0
  116. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/ibus.py +0 -0
  117. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/pynput_type.py +0 -0
  118. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/wtype.py +0 -0
  119. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/xdotool.py +0 -0
  120. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/typers/ydotool.py +0 -0
  121. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/vad.py +0 -0
  122. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/vocabulary.py +0 -0
  123. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/wizard.py +0 -0
  124. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/wordfreq.py +0 -0
  125. {python_voiceio-0.5.0 → python_voiceio-0.7.0}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.5.0"
7
+ version = "0.7.0"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.5.0
3
+ Version: 0.7.0
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -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