subpeeko 0.1.0__py3-none-any.whl
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.
- subpeeko/__init__.py +3 -0
- subpeeko/__main__.py +3 -0
- subpeeko/checkpoint.py +88 -0
- subpeeko/cli.py +266 -0
- subpeeko/config.py +171 -0
- subpeeko/data/LICENSE-KyleBing.txt +28 -0
- subpeeko/data/vocabulary-source.json +4 -0
- subpeeko/data/vocabulary.json +1 -0
- subpeeko/model.py +328 -0
- subpeeko/rate_limit.py +54 -0
- subpeeko/subtitles.py +187 -0
- subpeeko/vocabulary.py +79 -0
- subpeeko-0.1.0.dist-info/METADATA +240 -0
- subpeeko-0.1.0.dist-info/RECORD +20 -0
- subpeeko-0.1.0.dist-info/WHEEL +5 -0
- subpeeko-0.1.0.dist-info/entry_points.txt +2 -0
- subpeeko-0.1.0.dist-info/licenses/LICENSE +21 -0
- subpeeko-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +16 -0
- subpeeko-0.1.0.dist-info/licenses/src/subpeeko/data/LICENSE-KyleBing.txt +28 -0
- subpeeko-0.1.0.dist-info/top_level.txt +1 -0
subpeeko/__init__.py
ADDED
subpeeko/__main__.py
ADDED
subpeeko/checkpoint.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Local checkpoints for model results already reviewed for an exact input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import Lock
|
|
11
|
+
|
|
12
|
+
from . import model, subtitles, vocabulary
|
|
13
|
+
from .config import Config
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CheckpointError(Exception):
|
|
17
|
+
pass
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def cache_dir() -> Path:
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
root = Path(os.environ.get("LOCALAPPDATA") or Path.home() / "AppData" / "Local")
|
|
23
|
+
else:
|
|
24
|
+
root = Path(os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache")
|
|
25
|
+
if not root.is_absolute():
|
|
26
|
+
root = Path.home() / ".cache"
|
|
27
|
+
return root / "subpeeko" / "checkpoints"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def checkpoint_key(path: Path, source_hash: bytes, config: Config, level: str, *, batch_size: int,
|
|
31
|
+
thinking: str, max_output_tokens: int) -> str:
|
|
32
|
+
digest = hashlib.sha256()
|
|
33
|
+
try:
|
|
34
|
+
values = (
|
|
35
|
+
b"subpeeko-checkpoint-v1", str(path.resolve()).encode(), source_hash,
|
|
36
|
+
config.base_url.encode(), config.model_name.encode(), config.api_key.encode(),
|
|
37
|
+
level.encode(), str(batch_size).encode(), thinking.encode(), str(max_output_tokens).encode(),
|
|
38
|
+
Path(model.__file__).read_bytes(), Path(subtitles.__file__).read_bytes(),
|
|
39
|
+
Path(vocabulary.__file__).read_bytes(),
|
|
40
|
+
vocabulary.files("subpeeko").joinpath("data/vocabulary.json").read_bytes(),
|
|
41
|
+
)
|
|
42
|
+
except OSError as exc:
|
|
43
|
+
raise CheckpointError("Cannot read checkpoint inputs") from exc
|
|
44
|
+
for value in values:
|
|
45
|
+
digest.update(len(value).to_bytes(8, "big"))
|
|
46
|
+
digest.update(value)
|
|
47
|
+
return digest.hexdigest()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Checkpoint:
|
|
51
|
+
def __init__(self, key: str):
|
|
52
|
+
self.lock = Lock()
|
|
53
|
+
self.connection = None
|
|
54
|
+
try:
|
|
55
|
+
root = cache_dir()
|
|
56
|
+
root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
57
|
+
path = root / f"{key}.sqlite3"
|
|
58
|
+
descriptor = os.open(path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
59
|
+
os.close(descriptor)
|
|
60
|
+
self.connection = sqlite3.connect(path, timeout=30, check_same_thread=False)
|
|
61
|
+
self.connection.execute("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, note TEXT NOT NULL)")
|
|
62
|
+
self.connection.commit()
|
|
63
|
+
except (OSError, sqlite3.Error) as exc:
|
|
64
|
+
if self.connection is not None:
|
|
65
|
+
self.connection.close()
|
|
66
|
+
raise CheckpointError("Cannot open the local checkpoint; use --no-resume to disable it") from exc
|
|
67
|
+
|
|
68
|
+
def load(self, valid_ids: set[int]) -> dict[int, str]:
|
|
69
|
+
try:
|
|
70
|
+
with self.lock:
|
|
71
|
+
rows = self.connection.execute("SELECT id, note FROM notes").fetchall()
|
|
72
|
+
if any(type(identifier) is not int or identifier not in valid_ids or not isinstance(note, str)
|
|
73
|
+
or any(char in note for char in "{}\\\r\n") for identifier, note in rows):
|
|
74
|
+
raise CheckpointError("Local checkpoint contains invalid results; use --no-resume")
|
|
75
|
+
return dict(rows)
|
|
76
|
+
except sqlite3.Error as exc:
|
|
77
|
+
raise CheckpointError("Cannot read the local checkpoint; use --no-resume") from exc
|
|
78
|
+
|
|
79
|
+
def save(self, notes: dict[int, str]) -> None:
|
|
80
|
+
try:
|
|
81
|
+
with self.lock, self.connection:
|
|
82
|
+
self.connection.executemany("INSERT OR REPLACE INTO notes (id, note) VALUES (?, ?)", notes.items())
|
|
83
|
+
except sqlite3.Error as exc:
|
|
84
|
+
raise CheckpointError("Cannot save the local checkpoint; completed batches remain available") from exc
|
|
85
|
+
|
|
86
|
+
def close(self) -> None:
|
|
87
|
+
if self.connection is not None:
|
|
88
|
+
self.connection.close()
|
subpeeko/cli.py
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Command-line entry point for SubPeeko."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .checkpoint import Checkpoint, CheckpointError, checkpoint_key
|
|
15
|
+
from .config import ConfigError, configure, load_config, show_config
|
|
16
|
+
from .model import BATCH_SIZE, DEFAULT_CONCURRENCY, DEFAULT_RPM, DEFAULT_TPM, ModelClient, ModelError
|
|
17
|
+
from .subtitles import AssFile, SubtitleError
|
|
18
|
+
from .vocabulary import LEVELS
|
|
19
|
+
|
|
20
|
+
OUTPUT_SUFFIX = ".subpeeko.ass"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _batch_size(value: str) -> int:
|
|
24
|
+
try:
|
|
25
|
+
number = int(value)
|
|
26
|
+
if 1 <= number <= 100:
|
|
27
|
+
return number
|
|
28
|
+
except ValueError:
|
|
29
|
+
pass
|
|
30
|
+
raise argparse.ArgumentTypeError("batch size must be an integer between 1 and 100")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _positive_int(value: str) -> int:
|
|
34
|
+
try:
|
|
35
|
+
number = int(value)
|
|
36
|
+
if number > 0:
|
|
37
|
+
return number
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
raise argparse.ArgumentTypeError("must be a positive integer")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _positive_float(value: str) -> float:
|
|
44
|
+
try:
|
|
45
|
+
number = float(value)
|
|
46
|
+
if math.isfinite(number) and number > 0:
|
|
47
|
+
return number
|
|
48
|
+
except ValueError:
|
|
49
|
+
pass
|
|
50
|
+
raise argparse.ArgumentTypeError("must be a positive finite number")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _nonnegative_int(value: str) -> int:
|
|
54
|
+
try:
|
|
55
|
+
number = int(value)
|
|
56
|
+
if number >= 0:
|
|
57
|
+
return number
|
|
58
|
+
except ValueError:
|
|
59
|
+
pass
|
|
60
|
+
raise argparse.ArgumentTypeError("must be a nonnegative integer")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def parser() -> argparse.ArgumentParser:
|
|
64
|
+
result = argparse.ArgumentParser(
|
|
65
|
+
prog="subpeeko",
|
|
66
|
+
description="Create English-learning subtitles from bilingual ASS files.",
|
|
67
|
+
epilog="Run 'subpeeko config' to configure the model API."
|
|
68
|
+
)
|
|
69
|
+
result.add_argument("input", type=Path, help="an ASS file or a directory of ASS files")
|
|
70
|
+
result.add_argument("--level", choices=LEVELS, required=True, help="the learner's English level")
|
|
71
|
+
result.add_argument("--recursive", action="store_true", help="include subdirectories")
|
|
72
|
+
result.add_argument("--overwrite", action="store_true", help="replace existing generated output")
|
|
73
|
+
result.add_argument("--dry-run", action="store_true", help="list input and output paths without calling the model")
|
|
74
|
+
result.add_argument("--batch-size", type=_batch_size, default=BATCH_SIZE, help=f"dialogues per model request (default: {BATCH_SIZE})")
|
|
75
|
+
result.add_argument("--concurrency", type=_positive_int, default=DEFAULT_CONCURRENCY, help=f"parallel model requests (default: {DEFAULT_CONCURRENCY})")
|
|
76
|
+
result.add_argument("--rpm", type=_positive_int, default=DEFAULT_RPM, help=f"available requests per minute for this process (default: {DEFAULT_RPM})")
|
|
77
|
+
result.add_argument("--tpm", type=_positive_int, default=DEFAULT_TPM, help=f"available input and output tokens per minute (default: {DEFAULT_TPM})")
|
|
78
|
+
result.add_argument("--qps", type=_positive_float, help="maximum requests per second; defaults to RPM / 60")
|
|
79
|
+
result.add_argument("--max-output-tokens", type=_positive_int, default=2048, help="output token reservation when rate limiting (default: 2048)")
|
|
80
|
+
result.add_argument("--thinking", choices=("off", "on", "auto"), default="off", help="model thinking mode (default: off; auto omits the provider-specific setting)")
|
|
81
|
+
result.add_argument("--reasoning-reserve", type=_nonnegative_int, default=8192, help="additional reasoning-token reservation for providers without a combined cap (default: 8192)")
|
|
82
|
+
result.add_argument("--no-resume", action="store_true", help="do not read or write local checkpoints")
|
|
83
|
+
result.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
84
|
+
return result
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _config_command(argv: list[str]) -> int:
|
|
88
|
+
p = argparse.ArgumentParser(prog="subpeeko config", description="Manage model API configuration.")
|
|
89
|
+
p.add_argument("action", nargs="?", choices=("set", "show", "test"), default="set")
|
|
90
|
+
args = p.parse_args(argv)
|
|
91
|
+
if args.action == "set":
|
|
92
|
+
configure()
|
|
93
|
+
elif args.action == "show":
|
|
94
|
+
show_config()
|
|
95
|
+
else:
|
|
96
|
+
client = ModelClient(load_config())
|
|
97
|
+
client.test_connection()
|
|
98
|
+
print("Model API connection succeeded.")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def input_files(path: Path, recursive: bool) -> list[Path]:
|
|
103
|
+
if path.is_file():
|
|
104
|
+
if path.suffix.casefold() != ".ass":
|
|
105
|
+
raise SubtitleError("Input file must have an .ass extension")
|
|
106
|
+
if path.name.casefold().endswith(OUTPUT_SUFFIX):
|
|
107
|
+
raise SubtitleError("Generated SubPeeko subtitles cannot be used as input")
|
|
108
|
+
return [path]
|
|
109
|
+
if not path.is_dir():
|
|
110
|
+
raise SubtitleError(f"Input path does not exist: {path}")
|
|
111
|
+
items = path.rglob("*") if recursive else path.iterdir()
|
|
112
|
+
try:
|
|
113
|
+
return sorted(
|
|
114
|
+
(item for item in items if item.is_file() and item.suffix.casefold() == ".ass"
|
|
115
|
+
and not item.name.casefold().endswith(OUTPUT_SUFFIX)),
|
|
116
|
+
key=lambda item: str(item).casefold(),
|
|
117
|
+
)
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise SubtitleError(f"Cannot read directory: {path}") from exc
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def output_path(path: Path, level: str) -> Path:
|
|
123
|
+
return path.with_name(f"{path.stem}.{level}{OUTPUT_SUFFIX}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _write_output(path: Path, data: bytes, overwrite: bool) -> None:
|
|
127
|
+
if not overwrite:
|
|
128
|
+
try:
|
|
129
|
+
with path.open("xb") as destination:
|
|
130
|
+
try:
|
|
131
|
+
destination.write(data)
|
|
132
|
+
except OSError:
|
|
133
|
+
destination.close()
|
|
134
|
+
path.unlink(missing_ok=True)
|
|
135
|
+
raise
|
|
136
|
+
except FileExistsError as exc:
|
|
137
|
+
raise SubtitleError(f"Output already exists: {path} (use --overwrite to replace it)") from exc
|
|
138
|
+
except OSError as exc:
|
|
139
|
+
raise SubtitleError(f"Cannot write output: {path}") from exc
|
|
140
|
+
return
|
|
141
|
+
temporary = None
|
|
142
|
+
try:
|
|
143
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, prefix=".subpeeko-", suffix=".tmp", delete=False) as stream:
|
|
144
|
+
temporary = Path(stream.name)
|
|
145
|
+
stream.write(data)
|
|
146
|
+
os.replace(temporary, path)
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
raise SubtitleError(f"Cannot write output: {path}") from exc
|
|
149
|
+
finally:
|
|
150
|
+
if temporary is not None:
|
|
151
|
+
temporary.unlink(missing_ok=True)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _report_request(event: dict) -> None:
|
|
155
|
+
usage = event["usage"] or {}
|
|
156
|
+
def count(name):
|
|
157
|
+
value = usage.get(name)
|
|
158
|
+
return str(value) if type(value) is int and value >= 0 else "?"
|
|
159
|
+
print(
|
|
160
|
+
f" Request {event['id']}: targets={event['targets']}, status={event['status']}, "
|
|
161
|
+
f"network={event['request_seconds']:.2f}s, wait={event['wait_seconds']:.2f}s, "
|
|
162
|
+
f"tokens in/out/reasoning={count('prompt_tokens')}/{count('completion_tokens')}/{count('reasoning_tokens')}",
|
|
163
|
+
flush=True,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _convert(args: argparse.Namespace) -> int:
|
|
168
|
+
clients = []
|
|
169
|
+
started = time.monotonic()
|
|
170
|
+
try:
|
|
171
|
+
return _convert_files(args, clients)
|
|
172
|
+
finally:
|
|
173
|
+
if clients:
|
|
174
|
+
client = clients[0]
|
|
175
|
+
def total(key):
|
|
176
|
+
count = client.usage_counts[key]
|
|
177
|
+
if not count:
|
|
178
|
+
return "?"
|
|
179
|
+
value = str(client.usage[key])
|
|
180
|
+
return value if count == client.usage_responses else f"{value} (partial {count}/{client.usage_responses})"
|
|
181
|
+
print(f"Elapsed: {time.monotonic() - started:.2f}s. Usage: input={total('prompt_tokens')}, output={total('completion_tokens')}, "
|
|
182
|
+
f"reasoning={total('reasoning_tokens')}; usage reported for {client.usage_responses}/{client.requests} requests; "
|
|
183
|
+
f"{client.retries} retry/split event(s).")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _convert_files(args: argparse.Namespace, clients: list[ModelClient]) -> int:
|
|
187
|
+
paths = input_files(args.input, args.recursive)
|
|
188
|
+
if not paths:
|
|
189
|
+
print("No input ASS files found.")
|
|
190
|
+
return 0
|
|
191
|
+
if args.dry_run:
|
|
192
|
+
for path in paths:
|
|
193
|
+
print(f"{path} -> {output_path(path, args.level)}")
|
|
194
|
+
print(f"{len(paths)} file(s); no changes made and no model requests sent.")
|
|
195
|
+
return 0
|
|
196
|
+
|
|
197
|
+
client = None
|
|
198
|
+
succeeded = failed = 0
|
|
199
|
+
for path in paths:
|
|
200
|
+
output = output_path(path, args.level)
|
|
201
|
+
checkpoint = None
|
|
202
|
+
try:
|
|
203
|
+
if output.exists() and not args.overwrite:
|
|
204
|
+
raise SubtitleError(f"Output already exists: {output} (use --overwrite to replace it)")
|
|
205
|
+
subtitles = AssFile.read(path)
|
|
206
|
+
if not subtitles.dialogues:
|
|
207
|
+
raise SubtitleError("No supported bilingual dialogue found")
|
|
208
|
+
if client is None:
|
|
209
|
+
client = ModelClient(
|
|
210
|
+
load_config(), batch_size=args.batch_size, workers=args.concurrency,
|
|
211
|
+
rpm=args.rpm, tpm=args.tpm, qps=args.qps, max_output_tokens=args.max_output_tokens,
|
|
212
|
+
thinking=args.thinking, reasoning_reserve=args.reasoning_reserve, on_request=_report_request,
|
|
213
|
+
)
|
|
214
|
+
clients.append(client)
|
|
215
|
+
cached = {}
|
|
216
|
+
if not args.no_resume:
|
|
217
|
+
key = checkpoint_key(path, subtitles.source_hash, client.config, args.level,
|
|
218
|
+
batch_size=args.batch_size, thinking=client.thinking,
|
|
219
|
+
max_output_tokens=args.max_output_tokens)
|
|
220
|
+
checkpoint = Checkpoint(key)
|
|
221
|
+
cached = checkpoint.load({item.id for item in subtitles.dialogues})
|
|
222
|
+
if cached:
|
|
223
|
+
print(f"Resuming: {len(cached)}/{len(subtitles.dialogues)} dialogue(s) from local checkpoint.")
|
|
224
|
+
print(f"Processing: {path} ({len(subtitles.dialogues)} bilingual dialogue(s))", flush=True)
|
|
225
|
+
notes = client.analyze(
|
|
226
|
+
subtitles.dialogues, args.level,
|
|
227
|
+
progress=lambda done, total: print(f" Reviewed {done}/{total} dialogue(s).", flush=True),
|
|
228
|
+
on_retry=lambda message: print(f" {message}", file=sys.stderr, flush=True),
|
|
229
|
+
initial_results=cached, on_batch=checkpoint.save if checkpoint else None,
|
|
230
|
+
)
|
|
231
|
+
result = subtitles.render(notes)
|
|
232
|
+
_write_output(output, result, args.overwrite)
|
|
233
|
+
succeeded += 1
|
|
234
|
+
print(f"Created: {output} ({len(subtitles.dialogues)} bilingual dialogue(s))")
|
|
235
|
+
if any(len(note) > 90 for note in notes.values()):
|
|
236
|
+
print(" Some annotations may be too long for the available display time.")
|
|
237
|
+
except (CheckpointError, ConfigError, ModelError, SubtitleError) as exc:
|
|
238
|
+
failed += 1
|
|
239
|
+
print(f"Failed: {path}: {exc}", file=sys.stderr)
|
|
240
|
+
finally:
|
|
241
|
+
if checkpoint is not None:
|
|
242
|
+
checkpoint.close()
|
|
243
|
+
print(f"Finished: {succeeded} created, {failed} failed; {client.requests if client else 0} model request(s).")
|
|
244
|
+
return 1 if failed else 0
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def main(argv: list[str] | None = None) -> int:
|
|
248
|
+
# Redirected Windows output may use an encoding that cannot represent paths.
|
|
249
|
+
for stream in (sys.stdout, sys.stderr):
|
|
250
|
+
if hasattr(stream, "reconfigure"):
|
|
251
|
+
stream.reconfigure(errors="backslashreplace")
|
|
252
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
253
|
+
try:
|
|
254
|
+
if argv and argv[0] == "config":
|
|
255
|
+
return _config_command(argv[1:])
|
|
256
|
+
p = parser()
|
|
257
|
+
args = p.parse_args(argv)
|
|
258
|
+
if args.concurrency > 32:
|
|
259
|
+
p.error("--concurrency must not exceed 32")
|
|
260
|
+
return _convert(args)
|
|
261
|
+
except (CheckpointError, ConfigError, ModelError, SubtitleError) as exc:
|
|
262
|
+
print(f"Error: {exc}", file=sys.stderr)
|
|
263
|
+
return 1
|
|
264
|
+
except (KeyboardInterrupt, EOFError):
|
|
265
|
+
print("Interrupted.", file=sys.stderr)
|
|
266
|
+
return 130
|
subpeeko/config.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Local configuration and system credential storage."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
|
|
13
|
+
from dotenv import dotenv_values
|
|
14
|
+
|
|
15
|
+
SERVICE = "subpeeko"
|
|
16
|
+
KEY_NAME = "model-api-key"
|
|
17
|
+
SECURE_BACKENDS = {
|
|
18
|
+
"keyring.backends.Windows",
|
|
19
|
+
"keyring.backends.SecretService",
|
|
20
|
+
"keyring.backends.kwallet",
|
|
21
|
+
"keyring.backends.macOS",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Config:
|
|
31
|
+
base_url: str = ""
|
|
32
|
+
model_name: str = ""
|
|
33
|
+
api_key: str = field(default="", repr=False)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def config_path() -> Path:
|
|
37
|
+
if sys.platform == "win32":
|
|
38
|
+
root = Path(os.environ.get("APPDATA") or Path.home() / "AppData" / "Roaming")
|
|
39
|
+
elif sys.platform == "darwin":
|
|
40
|
+
root = Path.home() / "Library" / "Application Support"
|
|
41
|
+
else:
|
|
42
|
+
root = Path(os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config")
|
|
43
|
+
if not root.is_absolute():
|
|
44
|
+
root = Path.home() / ".config"
|
|
45
|
+
return root / SERVICE / "config.json"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _read_saved() -> dict[str, str]:
|
|
49
|
+
path = config_path()
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return {}
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
54
|
+
except (OSError, ValueError) as exc:
|
|
55
|
+
raise ConfigError(f"Cannot read configuration: {path}") from exc
|
|
56
|
+
if not isinstance(data, dict):
|
|
57
|
+
raise ConfigError(f"Invalid configuration: {path}")
|
|
58
|
+
return {key: value for key, value in data.items() if isinstance(value, str)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _keyring():
|
|
62
|
+
try:
|
|
63
|
+
import keyring
|
|
64
|
+
|
|
65
|
+
backend = keyring.get_keyring()
|
|
66
|
+
if type(backend).__module__ == "keyring.backends.chainer":
|
|
67
|
+
backend = next((item for item in backend.backends if type(item).__module__ in SECURE_BACKENDS), None)
|
|
68
|
+
if backend is None:
|
|
69
|
+
return None
|
|
70
|
+
if type(backend).__module__ not in SECURE_BACKENDS:
|
|
71
|
+
return None
|
|
72
|
+
return backend
|
|
73
|
+
except Exception:
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _stored_key() -> str:
|
|
78
|
+
backend = _keyring()
|
|
79
|
+
if backend is None:
|
|
80
|
+
return ""
|
|
81
|
+
try:
|
|
82
|
+
return backend.get_password(SERVICE, KEY_NAME) or ""
|
|
83
|
+
except Exception:
|
|
84
|
+
return ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_config(require: bool = True) -> Config:
|
|
88
|
+
saved = _read_saved()
|
|
89
|
+
try:
|
|
90
|
+
dotenv = dotenv_values(Path.cwd() / ".env")
|
|
91
|
+
except OSError as exc:
|
|
92
|
+
raise ConfigError("Cannot read the local .env file") from exc
|
|
93
|
+
|
|
94
|
+
def value(name: str, default: str = "") -> str:
|
|
95
|
+
return (os.environ.get(name) or dotenv.get(name) or default).strip()
|
|
96
|
+
|
|
97
|
+
config = Config(
|
|
98
|
+
base_url=value("SUBPEEKO_BASE_URL", saved.get("base_url", "")),
|
|
99
|
+
model_name=value("SUBPEEKO_MODEL_NAME", saved.get("model_name", "")),
|
|
100
|
+
api_key=value("SUBPEEKO_API_KEY") or _stored_key(),
|
|
101
|
+
)
|
|
102
|
+
if require:
|
|
103
|
+
missing = [
|
|
104
|
+
name for name, item in (
|
|
105
|
+
("base URL", config.base_url),
|
|
106
|
+
("model name", config.model_name),
|
|
107
|
+
("API key", config.api_key),
|
|
108
|
+
) if not item
|
|
109
|
+
]
|
|
110
|
+
if missing:
|
|
111
|
+
raise ConfigError(
|
|
112
|
+
"Missing " + ", ".join(missing)
|
|
113
|
+
+ ". Run 'subpeeko config' or set the SUBPEEKO_* environment variables."
|
|
114
|
+
)
|
|
115
|
+
validate_base_url(config.base_url)
|
|
116
|
+
return config
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def validate_base_url(url: str) -> None:
|
|
120
|
+
try:
|
|
121
|
+
parsed = urlsplit(url)
|
|
122
|
+
valid = (parsed.scheme in {"http", "https"} and parsed.hostname
|
|
123
|
+
and not parsed.username and not parsed.password and not parsed.query and not parsed.fragment)
|
|
124
|
+
except ValueError:
|
|
125
|
+
valid = False
|
|
126
|
+
if not valid:
|
|
127
|
+
raise ConfigError("Base URL must be an HTTP(S) API root without credentials or a query string")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def configure() -> None:
|
|
131
|
+
saved = _read_saved()
|
|
132
|
+
print("Configure an OpenAI-compatible Chat Completions API.")
|
|
133
|
+
base = input(f"Base URL [{saved.get('base_url', '')}]: ").strip() or saved.get("base_url", "")
|
|
134
|
+
model = input(f"Model name [{saved.get('model_name', '')}]: ").strip() or saved.get("model_name", "")
|
|
135
|
+
if not base or not model:
|
|
136
|
+
raise ConfigError("Both base URL and model name are required")
|
|
137
|
+
validate_base_url(base)
|
|
138
|
+
|
|
139
|
+
path = config_path()
|
|
140
|
+
try:
|
|
141
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
142
|
+
path.write_text(json.dumps({"base_url": base, "model_name": model}, indent=2) + "\n", encoding="utf-8")
|
|
143
|
+
except OSError as exc:
|
|
144
|
+
raise ConfigError(f"Cannot save configuration: {path}") from exc
|
|
145
|
+
|
|
146
|
+
backend = _keyring()
|
|
147
|
+
if backend is None:
|
|
148
|
+
print("No supported system credential store is available; set SUBPEEKO_API_KEY in the environment.")
|
|
149
|
+
else:
|
|
150
|
+
if not sys.stdin.isatty():
|
|
151
|
+
raise ConfigError("An interactive terminal is required to enter a key; use SUBPEEKO_API_KEY otherwise")
|
|
152
|
+
key = getpass.getpass("API key (leave blank to keep the existing key): ").strip()
|
|
153
|
+
if key:
|
|
154
|
+
try:
|
|
155
|
+
backend.set_password(SERVICE, KEY_NAME, key)
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
raise ConfigError("Cannot save the API key in the system credential store; use SUBPEEKO_API_KEY") from exc
|
|
158
|
+
print(f"Configuration saved to {path}. API keys are never stored in this file.")
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def show_config() -> None:
|
|
162
|
+
config = load_config(require=False)
|
|
163
|
+
base_url = config.base_url or "(not set)"
|
|
164
|
+
if config.base_url:
|
|
165
|
+
try:
|
|
166
|
+
validate_base_url(config.base_url)
|
|
167
|
+
except ConfigError:
|
|
168
|
+
base_url = "(invalid URL)"
|
|
169
|
+
print(f"Base URL: {base_url}")
|
|
170
|
+
print(f"Model: {config.model_name or '(not set)'}")
|
|
171
|
+
print(f"API key: {'configured' if config.api_key else '(not set)'}")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022-2026, KyleBing
|
|
4
|
+
|
|
5
|
+
Redistribution and use in source and binary forms, with or without
|
|
6
|
+
modification, are permitted provided that the following conditions are met:
|
|
7
|
+
|
|
8
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
9
|
+
list of conditions and the following disclaimer.
|
|
10
|
+
|
|
11
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
12
|
+
this list of conditions and the following disclaimer in the documentation
|
|
13
|
+
and/or other materials provided with the distribution.
|
|
14
|
+
|
|
15
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
16
|
+
contributors may be used to endorse or promote products derived from
|
|
17
|
+
this software without specific prior written permission.
|
|
18
|
+
|
|
19
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
20
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
21
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
22
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
23
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
24
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
25
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
26
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
27
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
28
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|