python-voiceio 0.6.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.6.0/python_voiceio.egg-info → python_voiceio-0.7.0}/PKG-INFO +1 -1
  2. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/pyproject.toml +1 -1
  3. {python_voiceio-0.6.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.6.0 → python_voiceio-0.7.0}/tests/test_streaming.py +81 -0
  6. python_voiceio-0.7.0/voiceio/__init__.py +1 -0
  7. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/config.py +6 -1
  8. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/llm_api.py +60 -11
  9. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/streaming.py +22 -1
  10. python_voiceio-0.6.0/tests/test_llm_api.py +0 -220
  11. python_voiceio-0.6.0/voiceio/__init__.py +0 -1
  12. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/LICENSE +0 -0
  13. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/README.md +0 -0
  14. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
  15. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
  16. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/entry_points.txt +0 -0
  17. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/requires.txt +0 -0
  18. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/top_level.txt +0 -0
  19. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/setup.cfg +0 -0
  20. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_adjudicate.py +0 -0
  21. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_app_wiring.py +0 -0
  22. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_audio_quality.py +0 -0
  23. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_audit.py +0 -0
  24. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_autocorrect.py +0 -0
  25. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_autocorrect_state.py +0 -0
  26. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_backend_probes.py +0 -0
  27. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_cli.py +0 -0
  28. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_clipboard_read.py +0 -0
  29. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_commands.py +0 -0
  30. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_concurrency_lockdown.py +0 -0
  31. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_config.py +0 -0
  32. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_correct_batch.py +0 -0
  33. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_corrections.py +0 -0
  34. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_fallback.py +0 -0
  35. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_health.py +0 -0
  36. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_hints.py +0 -0
  37. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_history.py +0 -0
  38. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_ibus_pending.py +0 -0
  39. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_ibus_typer.py +0 -0
  40. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_llm.py +0 -0
  41. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_numbers.py +0 -0
  42. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_platform.py +0 -0
  43. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_postcorrect.py +0 -0
  44. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_postprocess.py +0 -0
  45. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_prebuffer.py +0 -0
  46. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_prompt.py +0 -0
  47. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_recorder_integration.py +0 -0
  48. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_retention.py +0 -0
  49. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_robustness.py +0 -0
  50. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_security_hardening.py +0 -0
  51. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_snapshots.py +0 -0
  52. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_transcriber.py +0 -0
  53. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_tts.py +0 -0
  54. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_vad.py +0 -0
  55. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_vocabulary.py +0 -0
  56. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_wizard.py +0 -0
  57. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_wordfreq.py +0 -0
  58. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/__main__.py +0 -0
  59. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/app.py +0 -0
  60. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/audit.py +0 -0
  61. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/autocorrect.py +0 -0
  62. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/autocorrect_state.py +0 -0
  63. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/backends.py +0 -0
  64. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/cli.py +0 -0
  65. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/clipboard_read.py +0 -0
  66. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/commands.py +0 -0
  67. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/consent.py +0 -0
  68. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/corrections.py +0 -0
  69. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/demo.py +0 -0
  70. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/feedback.py +0 -0
  71. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/health.py +0 -0
  72. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hints.py +0 -0
  73. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/history.py +0 -0
  74. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/__init__.py +0 -0
  75. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/base.py +0 -0
  76. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/chain.py +0 -0
  77. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/evdev.py +0 -0
  78. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/pynput_backend.py +0 -0
  79. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/socket_backend.py +0 -0
  80. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/ibus/__init__.py +0 -0
  81. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/ibus/engine.py +0 -0
  82. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/ibus/pending.py +0 -0
  83. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/llm.py +0 -0
  84. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/models/__init__.py +0 -0
  85. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/models/silero_vad.onnx +0 -0
  86. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/numbers.py +0 -0
  87. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/pidlock.py +0 -0
  88. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/platform.py +0 -0
  89. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/postcorrect.py +0 -0
  90. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/postprocess.py +0 -0
  91. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/prompt.py +0 -0
  92. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/recorder.py +0 -0
  93. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/retention.py +0 -0
  94. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/service.py +0 -0
  95. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/snapshots.py +0 -0
  96. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/__init__.py +0 -0
  97. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/commit.wav +0 -0
  98. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/start.wav +0 -0
  99. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/stop.wav +0 -0
  100. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/transcriber.py +0 -0
  101. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/__init__.py +0 -0
  102. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/_icons.py +0 -0
  103. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/_indicator.py +0 -0
  104. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/_pystray.py +0 -0
  105. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/__init__.py +0 -0
  106. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/base.py +0 -0
  107. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/chain.py +0 -0
  108. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/edge_engine.py +0 -0
  109. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/espeak.py +0 -0
  110. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/piper_engine.py +0 -0
  111. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/player.py +0 -0
  112. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/__init__.py +0 -0
  113. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/base.py +0 -0
  114. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/chain.py +0 -0
  115. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/clipboard.py +0 -0
  116. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/ibus.py +0 -0
  117. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/pynput_type.py +0 -0
  118. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/wtype.py +0 -0
  119. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/xdotool.py +0 -0
  120. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/ydotool.py +0 -0
  121. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/vad.py +0 -0
  122. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/vocabulary.py +0 -0
  123. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/wizard.py +0 -0
  124. {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/wordfreq.py +0 -0
  125. {python_voiceio-0.6.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.6.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.6.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.6.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
@@ -867,3 +867,84 @@ class TestDecodeFailureSafety:
867
867
  np.full(int(SR * 2.5), 0.005, dtype=np.float32), _silent(0.5),
868
868
  ])
869
869
  assert _tail_ends_in_silence(with_pause, SR)
870
+
871
+
872
+ class TestFreezeCalibration:
873
+ def test_hot_noise_floor_pause_is_silence(self):
874
+ """Real-mic calibration: noise floor ~0.3x of tail RMS IS a pause."""
875
+ from voiceio.streaming import _tail_ends_in_silence
876
+ speech = np.full(int(SR * 2.5), 0.12, dtype=np.float32)
877
+ pause = np.full(int(SR * 0.5), 0.03, dtype=np.float32) # noise floor
878
+ assert _tail_ends_in_silence(np.concatenate([speech, pause]), SR)
879
+
880
+ def test_speech_level_window_is_not_silence(self):
881
+ from voiceio.streaming import _tail_ends_in_silence
882
+ tail = np.full(SR * 3, 0.12, dtype=np.float32)
883
+ assert not _tail_ends_in_silence(tail, SR)
884
+
885
+
886
+ class TestDecodeBackpressure:
887
+ def _make_session(self):
888
+ recorder = MagicMock()
889
+ recorder.sample_rate = SR
890
+ transcriber = MagicMock()
891
+ transcriber.last_segments = []
892
+ s = StreamingSession(
893
+ transcriber=transcriber,
894
+ typer=MagicMock(spec=TyperBackend),
895
+ recorder=recorder,
896
+ )
897
+ return s, transcriber, recorder
898
+
899
+ def test_interim_skipped_while_behind(self):
900
+ """No new interim until new audio >= duration of the last decode."""
901
+ s, tr, rec = self._make_session()
902
+ s._last_decode_end = SR * 10
903
+ s._last_decode_secs = 8.0 # last decode took 8s (contended CPU)
904
+ rec.get_audio_so_far.return_value = _loud(13) # only 3s new audio
905
+ s._transcribe_and_apply()
906
+ tr.transcribe.assert_not_called()
907
+
908
+ def test_interim_runs_once_caught_up(self):
909
+ s, tr, rec = self._make_session()
910
+ s._last_decode_end = SR * 10
911
+ s._last_decode_secs = 8.0
912
+ rec.get_audio_so_far.return_value = _loud(19) # 9s new audio > 8s
913
+ tr.transcribe.return_value = "hello"
914
+ s._transcribe_and_apply()
915
+ tr.transcribe.assert_called_once()
916
+ assert s._last_decode_end == SR * 19
917
+
918
+ def test_final_never_skipped_by_backpressure(self):
919
+ s, tr, rec = self._make_session()
920
+ s._last_decode_end = SR * 10
921
+ s._last_decode_secs = 60.0
922
+ s._final_audio = _loud(11)
923
+ tr.transcribe.return_value = "hello"
924
+ s._transcribe_and_apply(min_seconds=0.5, final=True)
925
+ tr.transcribe.assert_called_once()
926
+
927
+
928
+ class TestStaleInterimAfterStop:
929
+ def test_interim_completing_after_stop_is_discarded(self):
930
+ """A slow interim decode finishing post-stop must not touch the display."""
931
+ recorder = MagicMock()
932
+ recorder.sample_rate = SR
933
+ transcriber = MagicMock()
934
+ transcriber.last_segments = []
935
+ s = StreamingSession(
936
+ transcriber=transcriber,
937
+ typer=MagicMock(spec=StreamingTyper),
938
+ recorder=recorder,
939
+ )
940
+ s._typed_text = "Okay."
941
+ recorder.get_audio_so_far.return_value = _loud(8)
942
+
943
+ def slow_then_stopped(audio, final=False, context=None):
944
+ s._stop_event.set() # stop pressed while decode in flight
945
+ return "Okay, two things. One, is it"
946
+
947
+ transcriber.transcribe.side_effect = slow_then_stopped
948
+ s._transcribe_and_apply()
949
+ s._typer.update_preedit.assert_not_called()
950
+ assert s._typed_text == "Okay." # display state untouched
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
@@ -147,7 +147,12 @@ class PostCorrectConfig:
147
147
  model can be overridden here; empty falls back to the autocorrect model.
148
148
  """
149
149
  enabled: bool = False
150
- model: str = "" # empty = use [autocorrect].model
150
+ # Latency-critical (blocks the commit): default to a fast cheap model.
151
+ # Bake-off on the real correction prompt: gemini-2.5-flash-lite 0.44s
152
+ # with fixes identical to kimi-k2's 2.0s. OpenRouter model id — on a
153
+ # custom base_url set a model your endpoint serves (empty = use
154
+ # [autocorrect].model).
155
+ model: str = "google/gemini-2.5-flash-lite"
151
156
  timeout_secs: float = 8.0
152
157
  min_words: int = 4 # skip utterances shorter than this
153
158
 
@@ -5,16 +5,73 @@ local Ollama (via /v1/chat/completions), etc. Zero dependencies beyond stdlib.
5
5
  """
6
6
  from __future__ import annotations
7
7
 
8
+ import http.client
9
+ import io
8
10
  import json
9
11
  import logging
10
12
  import os
13
+ import threading
11
14
  import urllib.error
12
- import urllib.request
15
+ from urllib.parse import urlsplit
13
16
 
14
17
  from voiceio.config import AutocorrectConfig
15
18
 
16
19
  log = logging.getLogger(__name__)
17
20
 
21
+ # Keep-alive connection pool: a fresh TLS handshake costs ~0.2-0.5s per call,
22
+ # which lands directly on postcorrect's stop-to-commit latency. Connections
23
+ # are checked OUT for the duration of a request and returned only on clean
24
+ # completion, so a deadline-abandoned postcorrect thread can never share a
25
+ # socket with a later call.
26
+ _POOL_LOCK = threading.Lock()
27
+ _CONN_POOL: dict[str, http.client.HTTPConnection] = {}
28
+
29
+
30
+ def _post_json(url: str, headers: dict, body: dict, timeout: float) -> dict:
31
+ """POST JSON over a pooled keep-alive connection; return the parsed reply.
32
+
33
+ A stale pooled connection (server closed it while idle) gets one retry on
34
+ a fresh one. Non-2xx raises urllib.error.HTTPError so callers keep their
35
+ existing status-code handling.
36
+ """
37
+ parts = urlsplit(url)
38
+ pool_key = f"{parts.scheme}://{parts.netloc}"
39
+ path = parts.path or "/"
40
+ payload = json.dumps(body).encode()
41
+
42
+ for attempt in (1, 2):
43
+ with _POOL_LOCK:
44
+ conn = _CONN_POOL.pop(pool_key, None)
45
+ pooled = conn is not None
46
+ if conn is None:
47
+ cls = (http.client.HTTPSConnection if parts.scheme == "https"
48
+ else http.client.HTTPConnection)
49
+ conn = cls(parts.hostname, parts.port, timeout=timeout)
50
+ elif conn.sock is not None:
51
+ conn.sock.settimeout(timeout)
52
+ try:
53
+ conn.request("POST", path, body=payload, headers=headers)
54
+ resp = conn.getresponse()
55
+ data = resp.read()
56
+ except (http.client.HTTPException, OSError):
57
+ conn.close()
58
+ if not pooled or attempt == 2:
59
+ raise
60
+ continue # stale keep-alive — retry once on a fresh connection
61
+ if resp.status // 100 != 2:
62
+ conn.close()
63
+ raise urllib.error.HTTPError(
64
+ url, resp.status, resp.reason, dict(resp.getheaders()),
65
+ io.BytesIO(data),
66
+ )
67
+ with _POOL_LOCK:
68
+ if pool_key in _CONN_POOL:
69
+ conn.close() # keep at most one idle connection per host
70
+ else:
71
+ _CONN_POOL[pool_key] = conn
72
+ return json.loads(data)
73
+ raise AssertionError("unreachable")
74
+
18
75
 
19
76
  _LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1")
20
77
  _consent_warned = False
@@ -94,11 +151,7 @@ def _anthropic_request(
94
151
  "anthropic-version": "2023-06-01",
95
152
  }
96
153
 
97
- req = urllib.request.Request(
98
- url, data=json.dumps(body).encode(), headers=headers, method="POST",
99
- )
100
- with urllib.request.urlopen(req, timeout=timeout) as resp:
101
- data = json.loads(resp.read())
154
+ data = _post_json(url, headers, body, timeout)
102
155
  # Anthropic returns content as a list of blocks; some thinking models
103
156
  # may also include `thinking` blocks which we ignore.
104
157
  blocks = data.get("content") or []
@@ -137,11 +190,7 @@ def _openai_request(
137
190
  "Authorization": f"Bearer {api_key}",
138
191
  }
139
192
 
140
- req = urllib.request.Request(
141
- url, data=json.dumps(body).encode(), headers=headers, method="POST",
142
- )
143
- with urllib.request.urlopen(req, timeout=timeout) as resp:
144
- data = json.loads(resp.read())
193
+ data = _post_json(url, headers, body, timeout)
145
194
  # Be defensive: thinking models (Kimi K2.6, GPT reasoning, etc.) can
146
195
  # return content=None when the answer is in `reasoning` / `reasoning_content`
147
196
  # instead. Also some malformed responses lack `choices` entirely.
@@ -32,7 +32,10 @@ _FREEZE_SILENCE_WINDOW_SECS = 0.3
32
32
  # and the decode path normalizes audio anyway, so an absolute threshold
33
33
  # would read a quiet mic's speech as silence (mid-word cuts) and a hot
34
34
  # noise floor as speech (freeze never fires).
35
- _FREEZE_SILENCE_RATIO = 0.15
35
+ # Calibrated on real recordings: between-words noise floor sits at
36
+ # ~0.27-0.38 of overall tail RMS, speech windows at >=0.9. 0.6 separates
37
+ # both with margin; 0.15 never fired on a mic with a normal noise floor.
38
+ _FREEZE_SILENCE_RATIO = 0.6
36
39
  _FREEZE_SILENCE_FLOOR = 1e-4 # digital silence is always quiet
37
40
  # Whisper conditions on ~224 prompt tokens; more frozen context is wasted.
38
41
  _FREEZE_CONTEXT_CHARS = 400
@@ -158,6 +161,12 @@ class StreamingSession:
158
161
  self._frozen_samples = 0
159
162
  self._frozen_raw = ""
160
163
  self._frozen_segments: list[dict] = []
164
+ # Decode backpressure: when the CPU is contended, decodes slow down
165
+ # and 1s-tick interim passes would queue back-to-back, each covering
166
+ # a longer tail — a starvation spiral. Require at least as much NEW
167
+ # audio as the last decode took before starting another interim.
168
+ self._last_decode_end = 0 # samples covered by the last decode
169
+ self._last_decode_secs = 0.0 # how long that decode took
161
170
  # Raw (pre-pipeline) text + confidence of the final pass, for history
162
171
  self.raw_final_text: str | None = None
163
172
  self.final_latency: dict = {}
@@ -294,6 +303,9 @@ class StreamingSession:
294
303
  tail = audio[self._frozen_samples:]
295
304
  if not final and len(tail) < self._sample_rate * min_seconds:
296
305
  return # not enough new audio since the last freeze
306
+ grown_secs = (len(audio) - self._last_decode_end) / self._sample_rate
307
+ if not final and grown_secs < max(min_seconds, self._last_decode_secs):
308
+ return # backpressure: don't decode faster than we can keep up
297
309
 
298
310
  # Freeze when the tail has grown long AND ends at a speech pause
299
311
  # (never cut mid-word). The freeze pass gets beam search because its
@@ -333,6 +345,8 @@ class StreamingSession:
333
345
  return
334
346
  tail_segments = list(getattr(self._transcriber, "last_segments", []))
335
347
  t_transcribe = time.monotonic() - t0
348
+ self._last_decode_end = len(audio)
349
+ self._last_decode_secs = t_transcribe
336
350
 
337
351
  self.trace.append({
338
352
  "t": round(time.monotonic() - self._t0, 2),
@@ -353,6 +367,13 @@ class StreamingSession:
353
367
  )
354
368
  raw = self._frozen_raw if freeze else _join_text(self._frozen_raw, tail_text)
355
369
 
370
+ # A slow interim decode can complete AFTER the user pressed stop
371
+ # (observed: 29s under CPU contention). Applying it would overwrite
372
+ # the preedit with stale partial text moments before the final
373
+ # commit rewrites it again — discard it. Freeze bookkeeping above
374
+ # is kept: it is beam-quality and shrinks the final tail.
375
+ if not final and self._stop_event.is_set():
376
+ return
356
377
  # An interim pass that decoded nothing must not touch the display:
357
378
  # rewriting to frozen-only text would visibly delete the un-frozen
358
379
  # words the previous pass just showed (transient decoder flake).
@@ -1,220 +0,0 @@
1
- """Tests for voiceio.llm_api — OpenAI-compatible chat completions client."""
2
- from __future__ import annotations
3
-
4
- import json
5
- import urllib.error
6
- from unittest.mock import MagicMock, patch
7
-
8
- from voiceio.config import AutocorrectConfig
9
- from voiceio.llm_api import chat, check_api_key, detect_provider, resolve_api_key
10
-
11
-
12
- def _mock_response(data: dict) -> MagicMock:
13
- resp = MagicMock()
14
- resp.read.return_value = json.dumps(data).encode()
15
- resp.__enter__ = lambda s: s
16
- resp.__exit__ = MagicMock(return_value=False)
17
- return resp
18
-
19
-
20
- def _cfg(**kwargs) -> AutocorrectConfig:
21
- defaults = {"api_key": "test-key", "model": "test-model"}
22
- defaults.update(kwargs)
23
- return AutocorrectConfig(**defaults)
24
-
25
-
26
- # ── resolve_api_key ──────────────────────────────────────────────────────
27
-
28
-
29
- def test_resolve_from_config():
30
- cfg = _cfg(api_key="sk-from-config")
31
- assert resolve_api_key(cfg) == "sk-from-config"
32
-
33
-
34
- @patch.dict("os.environ", {"OPENROUTER_API_KEY": "sk-from-env"}, clear=False)
35
- def test_resolve_from_env():
36
- cfg = _cfg(api_key="")
37
- assert resolve_api_key(cfg) == "sk-from-env"
38
-
39
-
40
- @patch.dict("os.environ", {}, clear=True)
41
- def test_resolve_empty():
42
- cfg = _cfg(api_key="")
43
- assert resolve_api_key(cfg) == ""
44
-
45
-
46
- # ── chat ─────────────────────────────────────────────────────────────────
47
-
48
-
49
- @patch("urllib.request.urlopen")
50
- def test_chat_success(mock_urlopen):
51
- mock_urlopen.return_value = _mock_response({
52
- "choices": [{"message": {"content": "Fixed text."}}]
53
- })
54
- cfg = _cfg()
55
- result = chat(cfg, "system prompt", "user message")
56
- assert result == "Fixed text."
57
-
58
- # Verify request format
59
- call_args = mock_urlopen.call_args
60
- req = call_args[0][0]
61
- assert req.get_header("Authorization") == "Bearer test-key"
62
- assert req.get_header("Content-type") == "application/json"
63
- body = json.loads(req.data)
64
- assert body["model"] == "test-model"
65
- assert body["messages"][0]["role"] == "system"
66
- assert body["messages"][1]["role"] == "user"
67
-
68
-
69
- @patch("urllib.request.urlopen")
70
- def test_chat_timeout(mock_urlopen):
71
- mock_urlopen.side_effect = urllib.error.URLError("timed out")
72
- cfg = _cfg()
73
- assert chat(cfg, "sys", "msg") is None
74
-
75
-
76
- @patch("urllib.request.urlopen")
77
- def test_chat_http_401(mock_urlopen):
78
- err = urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
79
- err.read = lambda: b"invalid key"
80
- mock_urlopen.side_effect = err
81
- cfg = _cfg()
82
- assert chat(cfg, "sys", "msg") is None
83
-
84
-
85
- def test_chat_no_api_key():
86
- cfg = _cfg(api_key="")
87
- with patch.dict("os.environ", {}, clear=True):
88
- assert chat(cfg, "sys", "msg") is None
89
-
90
-
91
- @patch("urllib.request.urlopen")
92
- def test_chat_malformed_response(mock_urlopen):
93
- mock_urlopen.return_value = _mock_response({"unexpected": "format"})
94
- cfg = _cfg()
95
- assert chat(cfg, "sys", "msg") is None
96
-
97
-
98
- @patch("urllib.request.urlopen")
99
- def test_chat_null_content_returns_none(mock_urlopen):
100
- """Thinking models (Kimi K2.6 etc.) can return content=None — must not crash."""
101
- mock_urlopen.return_value = _mock_response({
102
- "choices": [{"message": {"content": None}}],
103
- })
104
- cfg = _cfg()
105
- assert chat(cfg, "sys", "msg") is None
106
-
107
-
108
- @patch("urllib.request.urlopen")
109
- def test_chat_falls_back_to_reasoning_field(mock_urlopen):
110
- """When content is null but reasoning is present, use reasoning text."""
111
- mock_urlopen.return_value = _mock_response({
112
- "choices": [{"message": {
113
- "content": None,
114
- "reasoning": "the answer",
115
- }}],
116
- })
117
- cfg = _cfg()
118
- assert chat(cfg, "sys", "msg") == "the answer"
119
-
120
-
121
- @patch("urllib.request.urlopen")
122
- def test_chat_falls_back_to_reasoning_content_field(mock_urlopen):
123
- """Some providers expose `reasoning_content` instead of `reasoning`."""
124
- mock_urlopen.return_value = _mock_response({
125
- "choices": [{"message": {
126
- "content": None,
127
- "reasoning_content": "thought-through answer",
128
- }}],
129
- })
130
- cfg = _cfg()
131
- assert chat(cfg, "sys", "msg") == "thought-through answer"
132
-
133
-
134
- @patch("urllib.request.urlopen")
135
- def test_chat_anthropic_null_content_array(mock_urlopen):
136
- """Anthropic native API: content=null shouldn't crash."""
137
- mock_urlopen.return_value = _mock_response({"content": None})
138
- cfg = _cfg(base_url="https://api.anthropic.com/v1")
139
- assert chat(cfg, "sys", "msg") is None
140
-
141
-
142
- # ── check_api_key ────────────────────────────────────────────────────────
143
-
144
-
145
- @patch("urllib.request.urlopen")
146
- def test_check_valid_key(mock_urlopen):
147
- mock_urlopen.return_value = _mock_response({"choices": [{"message": {"content": ""}}]})
148
- cfg = _cfg()
149
- assert check_api_key(cfg, "sk-valid") is True
150
-
151
-
152
- @patch("urllib.request.urlopen")
153
- def test_check_invalid_key(mock_urlopen):
154
- mock_urlopen.side_effect = urllib.error.HTTPError("url", 401, "Unauthorized", {}, None)
155
- cfg = _cfg()
156
- assert check_api_key(cfg, "sk-invalid") is False
157
-
158
-
159
- def test_check_empty_key():
160
- cfg = _cfg(api_key="")
161
- with patch.dict("os.environ", {}, clear=True):
162
- assert check_api_key(cfg) is False
163
-
164
-
165
- # ── Anthropic native API ────────────────────────────────────────────────
166
-
167
-
168
- @patch("urllib.request.urlopen")
169
- def test_chat_anthropic_native(mock_urlopen):
170
- mock_urlopen.return_value = _mock_response({
171
- "content": [{"type": "text", "text": "Fixed text."}]
172
- })
173
- cfg = _cfg(base_url="https://api.anthropic.com/v1")
174
- result = chat(cfg, "system prompt", "user message")
175
- assert result == "Fixed text."
176
-
177
- req = mock_urlopen.call_args[0][0]
178
- assert req.get_header("X-api-key") == "test-key"
179
- assert req.get_header("Anthropic-version") == "2023-06-01"
180
- assert "Authorization" not in dict(req.header_items())
181
- body = json.loads(req.data)
182
- assert body["system"] == "system prompt"
183
- assert body["messages"] == [{"role": "user", "content": "user message"}]
184
- assert "/messages" in req.full_url
185
-
186
-
187
- @patch("urllib.request.urlopen")
188
- def test_check_api_key_anthropic(mock_urlopen):
189
- mock_urlopen.return_value = _mock_response({
190
- "content": [{"type": "text", "text": ""}]
191
- })
192
- cfg = _cfg(base_url="https://api.anthropic.com/v1")
193
- assert check_api_key(cfg, "sk-ant-test") is True
194
- req = mock_urlopen.call_args[0][0]
195
- assert "/messages" in req.full_url
196
-
197
-
198
- # ── detect_provider ─────────────────────────────────────────────────────
199
-
200
-
201
- def test_detect_openrouter():
202
- base_url, model = detect_provider("sk-or-abc123")
203
- assert "openrouter" in base_url
204
- assert "kimi" in model
205
-
206
-
207
- def test_detect_anthropic():
208
- base_url, model = detect_provider("sk-ant-abc123")
209
- assert "anthropic.com" in base_url
210
- assert "claude" in model
211
-
212
-
213
- def test_detect_openai():
214
- base_url, model = detect_provider("sk-proj-abc123")
215
- assert "openai.com" in base_url
216
-
217
-
218
- def test_detect_unknown_defaults_openrouter():
219
- base_url, _ = detect_provider("unknown-key-format")
220
- assert "openrouter" in base_url
@@ -1 +0,0 @@
1
- __version__ = "0.6.0"
File without changes
File without changes
File without changes