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.
- {python_voiceio-0.6.0/python_voiceio.egg-info → python_voiceio-0.7.0}/PKG-INFO +1 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/pyproject.toml +1 -1
- {python_voiceio-0.6.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.6.0 → python_voiceio-0.7.0}/tests/test_streaming.py +81 -0
- python_voiceio-0.7.0/voiceio/__init__.py +1 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/config.py +6 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/llm_api.py +60 -11
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/streaming.py +22 -1
- python_voiceio-0.6.0/tests/test_llm_api.py +0 -220
- python_voiceio-0.6.0/voiceio/__init__.py +0 -1
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/LICENSE +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/README.md +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/setup.cfg +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_audit.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_cli.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_commands.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_config.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_corrections.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_fallback.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_health.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_hints.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_history.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_llm.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_numbers.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_platform.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_prompt.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_retention.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_robustness.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_tts.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_vad.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_wizard.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/__main__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/app.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/audit.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/backends.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/cli.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/commands.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/consent.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/corrections.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/demo.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/feedback.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/health.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hints.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/history.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/llm.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/numbers.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/platform.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/prompt.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/recorder.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/retention.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/service.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/vad.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/wizard.py +0 -0
- {python_voiceio-0.6.0 → python_voiceio-0.7.0}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.6.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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|