python-voiceio 0.3.0__tar.gz → 0.3.1__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.3.0/python_voiceio.egg-info → python_voiceio-0.3.1}/PKG-INFO +1 -1
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/pyproject.toml +1 -1
- {python_voiceio-0.3.0 → python_voiceio-0.3.1/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_llm_api.py +59 -1
- python_voiceio-0.3.1/voiceio/__init__.py +1 -0
- python_voiceio-0.3.1/voiceio/llm_api.py +183 -0
- python_voiceio-0.3.0/voiceio/__init__.py +0 -1
- python_voiceio-0.3.0/voiceio/llm_api.py +0 -130
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/LICENSE +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/README.md +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/setup.cfg +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_commands.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_config.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_corrections.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_fallback.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_health.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_hints.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_history.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_llm.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_numbers.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_platform.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_prompt.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_streaming.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_tts.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_vad.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/__main__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/app.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/backends.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/cli.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/commands.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/config.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/corrections.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/demo.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/feedback.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/health.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hints.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/history.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/llm.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/numbers.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/platform.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/prompt.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/recorder.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/service.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/streaming.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/vad.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/wizard.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.3.0 → python_voiceio-0.3.1}/voiceio/worker.py +0 -0
|
@@ -6,7 +6,7 @@ import urllib.error
|
|
|
6
6
|
from unittest.mock import MagicMock, patch
|
|
7
7
|
|
|
8
8
|
from voiceio.config import AutocorrectConfig
|
|
9
|
-
from voiceio.llm_api import chat, check_api_key, resolve_api_key
|
|
9
|
+
from voiceio.llm_api import chat, check_api_key, detect_provider, resolve_api_key
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
def _mock_response(data: dict) -> MagicMock:
|
|
@@ -116,3 +116,61 @@ def test_check_empty_key():
|
|
|
116
116
|
cfg = _cfg(api_key="")
|
|
117
117
|
with patch.dict("os.environ", {}, clear=True):
|
|
118
118
|
assert check_api_key(cfg) is False
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ── Anthropic native API ────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@patch("urllib.request.urlopen")
|
|
125
|
+
def test_chat_anthropic_native(mock_urlopen):
|
|
126
|
+
mock_urlopen.return_value = _mock_response({
|
|
127
|
+
"content": [{"type": "text", "text": "Fixed text."}]
|
|
128
|
+
})
|
|
129
|
+
cfg = _cfg(base_url="https://api.anthropic.com/v1")
|
|
130
|
+
result = chat(cfg, "system prompt", "user message")
|
|
131
|
+
assert result == "Fixed text."
|
|
132
|
+
|
|
133
|
+
req = mock_urlopen.call_args[0][0]
|
|
134
|
+
assert req.get_header("X-api-key") == "test-key"
|
|
135
|
+
assert req.get_header("Anthropic-version") == "2023-06-01"
|
|
136
|
+
assert "Authorization" not in dict(req.header_items())
|
|
137
|
+
body = json.loads(req.data)
|
|
138
|
+
assert body["system"] == "system prompt"
|
|
139
|
+
assert body["messages"] == [{"role": "user", "content": "user message"}]
|
|
140
|
+
assert "/messages" in req.full_url
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@patch("urllib.request.urlopen")
|
|
144
|
+
def test_check_api_key_anthropic(mock_urlopen):
|
|
145
|
+
mock_urlopen.return_value = _mock_response({
|
|
146
|
+
"content": [{"type": "text", "text": ""}]
|
|
147
|
+
})
|
|
148
|
+
cfg = _cfg(base_url="https://api.anthropic.com/v1")
|
|
149
|
+
assert check_api_key(cfg, "sk-ant-test") is True
|
|
150
|
+
req = mock_urlopen.call_args[0][0]
|
|
151
|
+
assert "/messages" in req.full_url
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── detect_provider ─────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_detect_openrouter():
|
|
158
|
+
base_url, model = detect_provider("sk-or-abc123")
|
|
159
|
+
assert "openrouter" in base_url
|
|
160
|
+
assert "claude" in model
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_detect_anthropic():
|
|
164
|
+
base_url, model = detect_provider("sk-ant-abc123")
|
|
165
|
+
assert "anthropic.com" in base_url
|
|
166
|
+
assert "claude" in model
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def test_detect_openai():
|
|
170
|
+
base_url, model = detect_provider("sk-proj-abc123")
|
|
171
|
+
assert "openai.com" in base_url
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def test_detect_unknown_defaults_openrouter():
|
|
175
|
+
base_url, _ = detect_provider("unknown-key-format")
|
|
176
|
+
assert "openrouter" in base_url
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.1"
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Multi-provider chat completions API client.
|
|
2
|
+
|
|
3
|
+
Supports OpenRouter, OpenAI, Anthropic (native Messages API), Together, Groq,
|
|
4
|
+
local Ollama (via /v1/chat/completions), etc. Zero dependencies beyond stdlib.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.request
|
|
13
|
+
|
|
14
|
+
from voiceio.config import AutocorrectConfig
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_anthropic(base_url: str) -> bool:
|
|
20
|
+
"""Check if the base URL points to Anthropic's native API."""
|
|
21
|
+
return "api.anthropic.com" in base_url
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_api_key(cfg: AutocorrectConfig) -> str:
|
|
25
|
+
"""Resolve API key from config or environment variables."""
|
|
26
|
+
if cfg.api_key:
|
|
27
|
+
return cfg.api_key
|
|
28
|
+
# Check common env vars in priority order
|
|
29
|
+
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
|
|
30
|
+
val = os.environ.get(var, "")
|
|
31
|
+
if val:
|
|
32
|
+
return val
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _anthropic_request(
|
|
37
|
+
base_url: str,
|
|
38
|
+
model: str,
|
|
39
|
+
system: str,
|
|
40
|
+
messages: list[dict],
|
|
41
|
+
api_key: str,
|
|
42
|
+
max_tokens: int,
|
|
43
|
+
timeout: float,
|
|
44
|
+
) -> str | None:
|
|
45
|
+
"""Send a request using Anthropic's native Messages API."""
|
|
46
|
+
url = f"{base_url}/messages"
|
|
47
|
+
|
|
48
|
+
body: dict = {
|
|
49
|
+
"model": model,
|
|
50
|
+
"max_tokens": max_tokens,
|
|
51
|
+
"messages": messages,
|
|
52
|
+
}
|
|
53
|
+
if system:
|
|
54
|
+
body["system"] = system
|
|
55
|
+
|
|
56
|
+
headers = {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
"x-api-key": api_key,
|
|
59
|
+
"anthropic-version": "2023-06-01",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
req = urllib.request.Request(
|
|
63
|
+
url, data=json.dumps(body).encode(), headers=headers, method="POST",
|
|
64
|
+
)
|
|
65
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
66
|
+
data = json.loads(resp.read())
|
|
67
|
+
# Anthropic returns content as a list of blocks
|
|
68
|
+
blocks = data.get("content", [])
|
|
69
|
+
text = "".join(b.get("text", "") for b in blocks if b.get("type") == "text")
|
|
70
|
+
return text.strip() or None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _openai_request(
|
|
74
|
+
base_url: str,
|
|
75
|
+
model: str,
|
|
76
|
+
system: str,
|
|
77
|
+
messages: list[dict],
|
|
78
|
+
api_key: str,
|
|
79
|
+
max_tokens: int,
|
|
80
|
+
timeout: float,
|
|
81
|
+
) -> str | None:
|
|
82
|
+
"""Send a request using the OpenAI chat completions format."""
|
|
83
|
+
url = f"{base_url}/chat/completions"
|
|
84
|
+
|
|
85
|
+
all_messages = []
|
|
86
|
+
if system:
|
|
87
|
+
all_messages.append({"role": "system", "content": system})
|
|
88
|
+
all_messages.extend(messages)
|
|
89
|
+
|
|
90
|
+
body = {
|
|
91
|
+
"model": model,
|
|
92
|
+
"max_tokens": max_tokens,
|
|
93
|
+
"messages": all_messages,
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
headers = {
|
|
97
|
+
"Content-Type": "application/json",
|
|
98
|
+
"Authorization": f"Bearer {api_key}",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
req = urllib.request.Request(
|
|
102
|
+
url, data=json.dumps(body).encode(), headers=headers, method="POST",
|
|
103
|
+
)
|
|
104
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
105
|
+
data = json.loads(resp.read())
|
|
106
|
+
return data["choices"][0]["message"]["content"].strip()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def chat(
|
|
110
|
+
cfg: AutocorrectConfig,
|
|
111
|
+
system: str,
|
|
112
|
+
user_message: str,
|
|
113
|
+
*,
|
|
114
|
+
api_key: str = "",
|
|
115
|
+
max_tokens: int = 2048,
|
|
116
|
+
) -> str | None:
|
|
117
|
+
"""Send a chat completion request. Returns response text or None on failure.
|
|
118
|
+
|
|
119
|
+
Automatically detects Anthropic's native API vs OpenAI-compatible format
|
|
120
|
+
based on the configured base_url.
|
|
121
|
+
"""
|
|
122
|
+
key = api_key or resolve_api_key(cfg)
|
|
123
|
+
if not key:
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
base_url = cfg.base_url.rstrip("/")
|
|
127
|
+
messages = [{"role": "user", "content": user_message}]
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
if _is_anthropic(base_url):
|
|
131
|
+
return _anthropic_request(
|
|
132
|
+
base_url, cfg.model, system, messages, key, max_tokens, cfg.timeout_secs,
|
|
133
|
+
)
|
|
134
|
+
return _openai_request(
|
|
135
|
+
base_url, cfg.model, system, messages, key, max_tokens, cfg.timeout_secs,
|
|
136
|
+
)
|
|
137
|
+
except urllib.error.HTTPError as e:
|
|
138
|
+
body_text = ""
|
|
139
|
+
try:
|
|
140
|
+
body_text = e.read().decode()[:200]
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
log.warning("API request failed (HTTP %d): %s", e.code, body_text)
|
|
144
|
+
return None
|
|
145
|
+
except Exception as e:
|
|
146
|
+
log.warning("API request failed: %s", e)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def detect_provider(api_key: str) -> tuple[str, str]:
|
|
151
|
+
"""Detect provider from API key prefix. Returns (base_url, model)."""
|
|
152
|
+
if api_key.startswith("sk-or-"):
|
|
153
|
+
return "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4"
|
|
154
|
+
if api_key.startswith("sk-ant-"):
|
|
155
|
+
return "https://api.anthropic.com/v1", "claude-sonnet-4-20250514"
|
|
156
|
+
if api_key.startswith(("sk-proj-", "sk-")):
|
|
157
|
+
return "https://api.openai.com/v1", "gpt-4o-mini"
|
|
158
|
+
# Default to OpenRouter (works with most keys)
|
|
159
|
+
return "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_api_key(cfg: AutocorrectConfig, api_key: str = "") -> bool:
|
|
163
|
+
"""Validate an API key with a minimal request."""
|
|
164
|
+
key = api_key or resolve_api_key(cfg)
|
|
165
|
+
if not key:
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
base_url = cfg.base_url.rstrip("/")
|
|
169
|
+
messages = [{"role": "user", "content": "hi"}]
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
if _is_anthropic(base_url):
|
|
173
|
+
_anthropic_request(base_url, cfg.model, "", messages, key, 1, 10)
|
|
174
|
+
else:
|
|
175
|
+
_openai_request(base_url, cfg.model, "", messages, key, 1, 10)
|
|
176
|
+
return True
|
|
177
|
+
except urllib.error.HTTPError as e:
|
|
178
|
+
if e.code == 401:
|
|
179
|
+
return False
|
|
180
|
+
# Other errors (rate limit, etc.) mean the key itself is valid
|
|
181
|
+
return e.code != 403
|
|
182
|
+
except Exception:
|
|
183
|
+
return False
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.3.0"
|
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
"""OpenAI-compatible chat completions API client.
|
|
2
|
-
|
|
3
|
-
Supports any provider: OpenRouter, OpenAI, Anthropic, Together, Groq,
|
|
4
|
-
local Ollama (via /v1/chat/completions), etc. Zero dependencies beyond stdlib.
|
|
5
|
-
"""
|
|
6
|
-
from __future__ import annotations
|
|
7
|
-
|
|
8
|
-
import json
|
|
9
|
-
import logging
|
|
10
|
-
import os
|
|
11
|
-
import urllib.error
|
|
12
|
-
import urllib.request
|
|
13
|
-
|
|
14
|
-
from voiceio.config import AutocorrectConfig
|
|
15
|
-
|
|
16
|
-
log = logging.getLogger(__name__)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def resolve_api_key(cfg: AutocorrectConfig) -> str:
|
|
20
|
-
"""Resolve API key from config or environment variables."""
|
|
21
|
-
if cfg.api_key:
|
|
22
|
-
return cfg.api_key
|
|
23
|
-
# Check common env vars in priority order
|
|
24
|
-
for var in ("OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"):
|
|
25
|
-
val = os.environ.get(var, "")
|
|
26
|
-
if val:
|
|
27
|
-
return val
|
|
28
|
-
return ""
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def chat(
|
|
32
|
-
cfg: AutocorrectConfig,
|
|
33
|
-
system: str,
|
|
34
|
-
user_message: str,
|
|
35
|
-
*,
|
|
36
|
-
api_key: str = "",
|
|
37
|
-
max_tokens: int = 2048,
|
|
38
|
-
) -> str | None:
|
|
39
|
-
"""Send a chat completion request. Returns response text or None on failure.
|
|
40
|
-
|
|
41
|
-
Uses the OpenAI /v1/chat/completions format, which is supported by
|
|
42
|
-
OpenRouter, OpenAI, Anthropic, Together, Groq, Ollama, and others.
|
|
43
|
-
"""
|
|
44
|
-
key = api_key or resolve_api_key(cfg)
|
|
45
|
-
if not key:
|
|
46
|
-
return None
|
|
47
|
-
|
|
48
|
-
base_url = cfg.base_url.rstrip("/")
|
|
49
|
-
url = f"{base_url}/chat/completions"
|
|
50
|
-
|
|
51
|
-
body = {
|
|
52
|
-
"model": cfg.model,
|
|
53
|
-
"max_tokens": max_tokens,
|
|
54
|
-
"messages": [
|
|
55
|
-
{"role": "system", "content": system},
|
|
56
|
-
{"role": "user", "content": user_message},
|
|
57
|
-
],
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
payload = json.dumps(body).encode()
|
|
61
|
-
headers = {
|
|
62
|
-
"Content-Type": "application/json",
|
|
63
|
-
"Authorization": f"Bearer {key}",
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
try:
|
|
67
|
-
req = urllib.request.Request(url, data=payload, headers=headers, method="POST")
|
|
68
|
-
with urllib.request.urlopen(req, timeout=cfg.timeout_secs) as resp:
|
|
69
|
-
data = json.loads(resp.read())
|
|
70
|
-
return data["choices"][0]["message"]["content"].strip()
|
|
71
|
-
except urllib.error.HTTPError as e:
|
|
72
|
-
body_text = ""
|
|
73
|
-
try:
|
|
74
|
-
body_text = e.read().decode()[:200]
|
|
75
|
-
except Exception:
|
|
76
|
-
pass
|
|
77
|
-
log.warning("API request failed (HTTP %d): %s", e.code, body_text)
|
|
78
|
-
return None
|
|
79
|
-
except Exception as e:
|
|
80
|
-
log.warning("API request failed: %s", e)
|
|
81
|
-
return None
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def detect_provider(api_key: str) -> tuple[str, str]:
|
|
85
|
-
"""Detect provider from API key prefix. Returns (base_url, model)."""
|
|
86
|
-
if api_key.startswith("sk-or-"):
|
|
87
|
-
return "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4"
|
|
88
|
-
if api_key.startswith("sk-ant-"):
|
|
89
|
-
return "https://api.anthropic.com/v1", "claude-sonnet-4-20250514"
|
|
90
|
-
if api_key.startswith(("sk-proj-", "sk-")):
|
|
91
|
-
return "https://api.openai.com/v1", "gpt-4o-mini"
|
|
92
|
-
# Default to OpenRouter (works with most keys)
|
|
93
|
-
return "https://openrouter.ai/api/v1", "anthropic/claude-sonnet-4"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def check_api_key(cfg: AutocorrectConfig, api_key: str = "") -> bool:
|
|
97
|
-
"""Validate an API key with a minimal request."""
|
|
98
|
-
key = api_key or resolve_api_key(cfg)
|
|
99
|
-
if not key:
|
|
100
|
-
return False
|
|
101
|
-
|
|
102
|
-
base_url = cfg.base_url.rstrip("/")
|
|
103
|
-
url = f"{base_url}/chat/completions"
|
|
104
|
-
|
|
105
|
-
body = {
|
|
106
|
-
"model": cfg.model,
|
|
107
|
-
"max_tokens": 1,
|
|
108
|
-
"messages": [{"role": "user", "content": "hi"}],
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
try:
|
|
112
|
-
req = urllib.request.Request(
|
|
113
|
-
url,
|
|
114
|
-
data=json.dumps(body).encode(),
|
|
115
|
-
headers={
|
|
116
|
-
"Content-Type": "application/json",
|
|
117
|
-
"Authorization": f"Bearer {key}",
|
|
118
|
-
},
|
|
119
|
-
method="POST",
|
|
120
|
-
)
|
|
121
|
-
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
122
|
-
resp.read()
|
|
123
|
-
return True
|
|
124
|
-
except urllib.error.HTTPError as e:
|
|
125
|
-
if e.code == 401:
|
|
126
|
-
return False
|
|
127
|
-
# Other errors (rate limit, etc.) mean the key itself is valid
|
|
128
|
-
return e.code != 403
|
|
129
|
-
except Exception:
|
|
130
|
-
return False
|
|
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
|