zettcode-sdk 0.3.4__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.
- zettcode/daemon/__init__.py +17 -0
- zettcode/daemon/__main__.py +10 -0
- zettcode/daemon/cli.py +693 -0
- zettcode/daemon/companions.py +63 -0
- zettcode/daemon/process.py +559 -0
- zettcode/daemon/run.py +678 -0
- zettcode/daemon/state.py +356 -0
- zettcode/doctor.py +167 -0
- zettcode/mentions.py +320 -0
- zettcode/sdk/__init__.py +195 -0
- zettcode/sdk/builtins.py +608 -0
- zettcode/sdk/client.py +1810 -0
- zettcode/sdk/commands.py +9 -0
- zettcode/sdk/daemons.py +244 -0
- zettcode/sdk/errors.py +132 -0
- zettcode/sdk/events.py +434 -0
- zettcode/sdk/extensions/__init__.py +6 -0
- zettcode/sdk/extensions/mcp.py +276 -0
- zettcode/sdk/extensions/messaging.py +281 -0
- zettcode/sdk/handlers.py +126 -0
- zettcode/sdk/messenger.py +299 -0
- zettcode/sdk/models.py +156 -0
- zettcode/sdk/py.typed +0 -0
- zettcode/sdk/session.py +1212 -0
- zettcode/sdk/translation.py +384 -0
- zettcode/sdk/types.py +907 -0
- zettcode/table.py +95 -0
- zettcode_sdk-0.3.4.dist-info/METADATA +21 -0
- zettcode_sdk-0.3.4.dist-info/RECORD +30 -0
- zettcode_sdk-0.3.4.dist-info/WHEEL +4 -0
zettcode/daemon/state.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""What the daemons leave on disk: the folder each one records itself in, and the registry of those folders.
|
|
2
|
+
|
|
3
|
+
A daemon's conversation is an ordinary session, so it is stored where every session is
|
|
4
|
+
(``~/.zettcode/sessions/<workspace>/<session-id>/`` where the layout is that of the session store); nothing here
|
|
5
|
+
duplicates it. This module owns the other half — the process:
|
|
6
|
+
|
|
7
|
+
~/.zettcode/daemon/<workspace path, levels joined with ->/
|
|
8
|
+
├── workspace.txt <- the path the folder belongs to, as the session store writes it
|
|
9
|
+
└── <session-id>/
|
|
10
|
+
├── state.json <- pid, status, model, effort, when it started (see :class:`State`)
|
|
11
|
+
├── state.lock <- held while state.json is read and written, so two writers do not clobber
|
|
12
|
+
├── heartbeat <- touched every few seconds: a pid that lives but has stopped working
|
|
13
|
+
└── log <- what the daemon had to say: a failed turn, a refused message
|
|
14
|
+
|
|
15
|
+
The folder is named after the session id, so the id is the one handle a reader needs: ``daemon kill``, ``list`` and
|
|
16
|
+
``send_message(to=...)`` all take it. A :class:`Record` is one such folder; :class:`Registry` is all of them, and
|
|
17
|
+
:meth:`Registry.record` is the only way to a folder, created or not.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import shutil
|
|
25
|
+
import time
|
|
26
|
+
from dataclasses import asdict, dataclass, fields
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
from zettcode.filelock import SingletonLock
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING: # pragma: no cover - annotations only
|
|
33
|
+
from collections.abc import Callable
|
|
34
|
+
|
|
35
|
+
#: Bumped when the meaning of a field changes, so a file written by another version is not trusted blindly.
|
|
36
|
+
PROTOCOL = 1
|
|
37
|
+
|
|
38
|
+
#: How long a start may take before a state file that never appeared counts as a failed spawn.
|
|
39
|
+
START_SECONDS = 10.0
|
|
40
|
+
|
|
41
|
+
#: What a daemon is doing, as ``list`` words it. ``starting`` is a spawn the broker has not answered yet;
|
|
42
|
+
#: ``stopping`` is a kill that has been asked for and not yet landed.
|
|
43
|
+
STATUSES = ("starting", "idle", "working", "stopping")
|
|
44
|
+
|
|
45
|
+
#: How stale a heartbeat may be and still count as alive: three times the tick the daemon writes it on.
|
|
46
|
+
STALE_SECONDS = 30.0
|
|
47
|
+
|
|
48
|
+
#: The names of the files one daemon's folder holds.
|
|
49
|
+
STATE_FILE = "state.json"
|
|
50
|
+
BEAT_FILE = "heartbeat"
|
|
51
|
+
LOG_FILE = "log"
|
|
52
|
+
|
|
53
|
+
#: The lock a write to ``state.json`` is taken on: a daemon refreshes it while a reader may be relabelling it, and
|
|
54
|
+
#: a read-modify-write must not overwrite what the other just wrote.
|
|
55
|
+
STATE_LOCK = "state.lock"
|
|
56
|
+
|
|
57
|
+
#: How long a state write may wait for the other writer. Both hold it for one small file, so a wait is a moment.
|
|
58
|
+
STATE_LOCK_WAIT = 2.0
|
|
59
|
+
|
|
60
|
+
#: The file in the daemon folder that says which workspace path the folder belongs to.
|
|
61
|
+
OWNER_FILE = "workspace.txt"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pid_runs(pid: int) -> bool:
|
|
65
|
+
"""Return whether a process with this id exists on this machine.
|
|
66
|
+
|
|
67
|
+
POSIX asks with signal zero. Windows cannot: ``os.kill(pid, 0)`` there *is* ``CTRL_C_EVENT``, so it would send a
|
|
68
|
+
Ctrl+C rather than ask anything. It goes through the kernel's own handle instead.
|
|
69
|
+
"""
|
|
70
|
+
if pid <= 0:
|
|
71
|
+
return False
|
|
72
|
+
if os.name == "nt": # pragma: no cover - exercised on Windows only
|
|
73
|
+
return _windows_pid_runs(pid)
|
|
74
|
+
try:
|
|
75
|
+
os.kill(pid, 0)
|
|
76
|
+
except ProcessLookupError:
|
|
77
|
+
return False
|
|
78
|
+
except PermissionError:
|
|
79
|
+
return True # somebody else's process, so it is there
|
|
80
|
+
except OSError:
|
|
81
|
+
return False
|
|
82
|
+
return True
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _windows_pid_runs(pid: int) -> bool: # pragma: no cover - exercised on Windows only
|
|
86
|
+
"""Return whether a process with this id exists, by asking the kernel rather than signalling it."""
|
|
87
|
+
import ctypes
|
|
88
|
+
|
|
89
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) # type: ignore[attr-defined]
|
|
90
|
+
process_query_limited_information = 0x1000
|
|
91
|
+
still_active = 259
|
|
92
|
+
handle = kernel32.OpenProcess(process_query_limited_information, False, pid)
|
|
93
|
+
if not handle:
|
|
94
|
+
return False
|
|
95
|
+
try:
|
|
96
|
+
code = ctypes.c_ulong()
|
|
97
|
+
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
98
|
+
return False
|
|
99
|
+
return code.value == still_active
|
|
100
|
+
finally:
|
|
101
|
+
kernel32.CloseHandle(handle)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class State:
|
|
106
|
+
"""What one daemon says about itself, as its ``state.json`` holds it.
|
|
107
|
+
|
|
108
|
+
Attributes:
|
|
109
|
+
session_id: The session the daemon runs; its folder name, and the id a reader passes to ``kill``.
|
|
110
|
+
workspace: The directory it works in, absolute.
|
|
111
|
+
pid: The process id, so a live one can be found and a dead one noticed.
|
|
112
|
+
status: One of :data:`STATUSES`.
|
|
113
|
+
token: The word the process that spawned it waits for, so a spawn can tell its own child from an old one.
|
|
114
|
+
started_at: Seconds since the epoch, for the "for 8m" column.
|
|
115
|
+
model: The model it was started on, as the entry is shown; ``None`` until it has one.
|
|
116
|
+
effort: The reasoning level it was started on, or ``None`` for the model's own default.
|
|
117
|
+
title: The session's name, once it has one: what a reader recognises it by.
|
|
118
|
+
name: The label the reader gave this daemon when starting it, if any: what it is *for*.
|
|
119
|
+
permission: The shell level it runs at: ``deny``, ``safe`` or ``full``.
|
|
120
|
+
requested_model: A model a reader asked for while it runs, to be used from the next turn.
|
|
121
|
+
requested_effort: A reasoning level a reader asked for while it runs, likewise.
|
|
122
|
+
requested_error: Why the last requested model or level could not be put in force, or ``None``.
|
|
123
|
+
broker: Whether the session broker was reachable at the last check, or ``None`` before one.
|
|
124
|
+
input_tokens: Everything the session has sent to the model so far, cache reads included.
|
|
125
|
+
output_tokens: Everything the model has generated for it, reasoning included.
|
|
126
|
+
context_tokens: How full the context was on the newest call, or ``None`` before one was made.
|
|
127
|
+
context_window: The window of the model it runs on, or ``None`` when it recorded no model.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
session_id: str
|
|
131
|
+
workspace: str
|
|
132
|
+
pid: int
|
|
133
|
+
status: str
|
|
134
|
+
token: str
|
|
135
|
+
started_at: float
|
|
136
|
+
model: str | None = None
|
|
137
|
+
effort: str | None = None
|
|
138
|
+
title: str | None = None
|
|
139
|
+
name: str | None = None
|
|
140
|
+
permission: str | None = None
|
|
141
|
+
requested_model: str | None = None
|
|
142
|
+
requested_effort: str | None = None
|
|
143
|
+
requested_error: str | None = None
|
|
144
|
+
broker: bool | None = None
|
|
145
|
+
input_tokens: int | None = None
|
|
146
|
+
output_tokens: int | None = None
|
|
147
|
+
context_tokens: int | None = None
|
|
148
|
+
context_window: int | None = None
|
|
149
|
+
keep: bool = False
|
|
150
|
+
protocol: int = PROTOCOL
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def load(cls, folder: Path) -> State | None:
|
|
154
|
+
"""Return the state recorded in ``folder``, or ``None`` when there is none to believe.
|
|
155
|
+
|
|
156
|
+
A file that does not parse, or that names another protocol, is no answer: the folder is then reported by its
|
|
157
|
+
name alone, which is enough to kill it and see it is not one of ours.
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
data = json.loads((folder / STATE_FILE).read_text(encoding="utf-8"))
|
|
161
|
+
except (OSError, ValueError):
|
|
162
|
+
return None
|
|
163
|
+
if not isinstance(data, dict) or data.get("protocol") != PROTOCOL:
|
|
164
|
+
return None
|
|
165
|
+
known = {item.name for item in fields(cls)}
|
|
166
|
+
try:
|
|
167
|
+
return cls(**{key: value for key, value in data.items() if key in known})
|
|
168
|
+
except TypeError:
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
def save(self, folder: Path) -> None:
|
|
172
|
+
"""Write this state into ``folder`` atomically, so a reader never sees half of it."""
|
|
173
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
174
|
+
temporary = folder / f".state.{os.getpid()}.tmp"
|
|
175
|
+
temporary.write_text(json.dumps(asdict(self), indent=2) + "\n", encoding="utf-8")
|
|
176
|
+
os.replace(temporary, folder / STATE_FILE)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Record:
|
|
180
|
+
"""One daemon's folder: what it said about itself, and the proofs of life around it.
|
|
181
|
+
|
|
182
|
+
Reading a record reads its state once, at construction, and whether the process is there: a command that lists
|
|
183
|
+
ten daemons asks the disk ten times, and nothing between two reads of one record can change the answer without
|
|
184
|
+
the file changing too.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, folder: Path) -> None:
|
|
188
|
+
"""Take the folder, read what is in it, and decide whether its process is still there."""
|
|
189
|
+
self.folder = folder
|
|
190
|
+
self.state: State | None = State.load(folder)
|
|
191
|
+
self.alive = self._alive()
|
|
192
|
+
|
|
193
|
+
def __repr__(self) -> str: # pragma: no cover - debugging only
|
|
194
|
+
return f"Record({str(self.folder)!r}, {self.status})"
|
|
195
|
+
|
|
196
|
+
@property
|
|
197
|
+
def session_id(self) -> str:
|
|
198
|
+
"""Return the session the folder belongs to: the recorded one, or the folder's name."""
|
|
199
|
+
return self.state.session_id if self.state is not None else self.folder.name
|
|
200
|
+
|
|
201
|
+
@property
|
|
202
|
+
def status(self) -> str:
|
|
203
|
+
"""Return what to show as its status: ``dead`` when the process is gone, else the state it wrote."""
|
|
204
|
+
if not self.alive:
|
|
205
|
+
return "dead"
|
|
206
|
+
return self.state.status if self.state is not None else "unknown"
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def dead(self) -> bool:
|
|
210
|
+
"""Return whether this is an entry whose process is gone."""
|
|
211
|
+
return not self.alive
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def log_path(self) -> Path:
|
|
215
|
+
"""Return the file the daemon writes its own account into."""
|
|
216
|
+
return self.folder / LOG_FILE
|
|
217
|
+
|
|
218
|
+
def seconds(self, now: float | None = None) -> float:
|
|
219
|
+
"""Return how long it has been running, or zero when it never said."""
|
|
220
|
+
if self.state is None:
|
|
221
|
+
return 0.0
|
|
222
|
+
return max(0.0, (now if now is not None else time.time()) - self.state.started_at)
|
|
223
|
+
|
|
224
|
+
def _alive(self) -> bool:
|
|
225
|
+
"""Return whether the process the state names is running and keeping its heartbeat.
|
|
226
|
+
|
|
227
|
+
A pid alone is not enough: pids are reused, and a daemon that wedged now holds a file forever. The heartbeat
|
|
228
|
+
is the part a wedged process stops writing, so both have to say yes.
|
|
229
|
+
"""
|
|
230
|
+
if self.state is None or self.state.pid <= 0 or not pid_runs(self.state.pid):
|
|
231
|
+
return False
|
|
232
|
+
try:
|
|
233
|
+
age = time.time() - (self.folder / BEAT_FILE).stat().st_mtime
|
|
234
|
+
except OSError:
|
|
235
|
+
return False
|
|
236
|
+
return age <= STALE_SECONDS
|
|
237
|
+
|
|
238
|
+
def save(self, state: State) -> None:
|
|
239
|
+
"""Write this record's state, and believe what was written."""
|
|
240
|
+
state.save(self.folder)
|
|
241
|
+
self.state = state
|
|
242
|
+
|
|
243
|
+
def change(self, produce: Callable[[State | None], State], *, wait: float = STATE_LOCK_WAIT) -> State:
|
|
244
|
+
"""Read the state under a lock, let ``produce`` make the new one, write it, and believe it.
|
|
245
|
+
|
|
246
|
+
A daemon writes its state as a turn begins and ends; a reader changes its label while the daemon runs. Both
|
|
247
|
+
go through here, so the file is read *inside* the lock: a write cannot overwrite a field the other writer
|
|
248
|
+
put there after this caller read the record.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
produce: Makes the new state from the one on disk, or from ``None`` when there is none.
|
|
252
|
+
wait: Seconds to wait for the lock. A caller on an event loop passes ``0`` and takes a failure for an
|
|
253
|
+
answer, rather than blocking the loop on a lock another process holds for a moment.
|
|
254
|
+
"""
|
|
255
|
+
lock = SingletonLock(self.folder / STATE_LOCK)
|
|
256
|
+
if not lock.acquire(wait=wait):
|
|
257
|
+
raise OSError("another process is writing the daemon's state")
|
|
258
|
+
try:
|
|
259
|
+
state = produce(State.load(self.folder))
|
|
260
|
+
state.save(self.folder)
|
|
261
|
+
self.state = state
|
|
262
|
+
return state
|
|
263
|
+
finally:
|
|
264
|
+
lock.release()
|
|
265
|
+
|
|
266
|
+
def beat(self) -> None:
|
|
267
|
+
"""Write the heartbeat: proof that this process is still doing its rounds."""
|
|
268
|
+
try:
|
|
269
|
+
(self.folder / BEAT_FILE).write_text(str(time.time()), encoding="utf-8")
|
|
270
|
+
except OSError:
|
|
271
|
+
pass
|
|
272
|
+
|
|
273
|
+
def log(self, text: str) -> None:
|
|
274
|
+
"""Append one line to the daemon's log; a log that cannot be written is not worth failing a daemon over."""
|
|
275
|
+
stamp = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
276
|
+
try:
|
|
277
|
+
with self.log_path.open("a", encoding="utf-8") as log:
|
|
278
|
+
log.write(f"{stamp} {text}\n")
|
|
279
|
+
except OSError:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
def remove(self) -> None:
|
|
283
|
+
"""Remove this record and the folder it lives in, leaving its session where it is."""
|
|
284
|
+
shutil.rmtree(self.folder, ignore_errors=True)
|
|
285
|
+
parent = self.folder.parent
|
|
286
|
+
try:
|
|
287
|
+
if parent.is_dir() and not any(parent.iterdir()):
|
|
288
|
+
parent.rmdir()
|
|
289
|
+
except OSError:
|
|
290
|
+
pass
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Registry:
|
|
294
|
+
"""Every daemon recorded under one root: the folders, and the workspace each folder belongs to.
|
|
295
|
+
|
|
296
|
+
The root is ``$ZETTCODE_DAEMON_DIR`` or ``~/.zettcode/daemon``. A workspace's folders sit under a name derived
|
|
297
|
+
from its path by the session store's own helper, so the two trees say the same thing about the same project.
|
|
298
|
+
"""
|
|
299
|
+
|
|
300
|
+
def __init__(self, root: Path | None = None) -> None:
|
|
301
|
+
"""Point at a root, or at the default one."""
|
|
302
|
+
self.root = root if root is not None else _default_root()
|
|
303
|
+
|
|
304
|
+
def workspace(self, workspace: str | Path, *, create: bool = False) -> Path:
|
|
305
|
+
"""Return the folder this workspace's daemons are recorded in.
|
|
306
|
+
|
|
307
|
+
Args:
|
|
308
|
+
workspace: The directory the daemons work in.
|
|
309
|
+
create: Make the folder and write the note saying whose it is, when it is not there yet.
|
|
310
|
+
"""
|
|
311
|
+
# Imported here, not at the top: `engine.storage` is the session store's package, and loading it would make
|
|
312
|
+
# `daemon --help` pay for the whole store. Only two path helpers are wanted, and only when a folder is.
|
|
313
|
+
from zettcode.engine.storage.layout import claim_workspace, open_workspace
|
|
314
|
+
|
|
315
|
+
folder = open_workspace(self.root, workspace)
|
|
316
|
+
if create:
|
|
317
|
+
claim_workspace(folder, workspace)
|
|
318
|
+
return folder
|
|
319
|
+
|
|
320
|
+
def record(self, workspace: str | Path, session_id: str, *, create: bool = False) -> Record:
|
|
321
|
+
"""Return the record one daemon keeps itself in, made when ``create`` is set."""
|
|
322
|
+
folder = self.workspace(workspace, create=create) / session_id
|
|
323
|
+
if create:
|
|
324
|
+
folder.mkdir(parents=True, exist_ok=True)
|
|
325
|
+
return Record(folder)
|
|
326
|
+
|
|
327
|
+
def all(self) -> tuple[Record, ...]:
|
|
328
|
+
"""Return every daemon recorded under the root, whichever workspace it works in, in no particular order.
|
|
329
|
+
|
|
330
|
+
A folder with no readable state is still a record: its process may be there (a crash halfway through a
|
|
331
|
+
start), and a reader who wants it gone can kill it by the name of the folder.
|
|
332
|
+
"""
|
|
333
|
+
found: list[Record] = []
|
|
334
|
+
for workspace in self._folders(self.root):
|
|
335
|
+
for folder in self._folders(workspace):
|
|
336
|
+
if any((folder / name).exists() for name in (STATE_FILE, BEAT_FILE, LOG_FILE)):
|
|
337
|
+
found.append(Record(folder))
|
|
338
|
+
return tuple(found)
|
|
339
|
+
|
|
340
|
+
def find(self, session_id: str) -> Record | None:
|
|
341
|
+
"""Return the daemon whose session is ``session_id``, whichever workspace it works in."""
|
|
342
|
+
return next((record for record in self.all() if record.session_id == session_id), None)
|
|
343
|
+
|
|
344
|
+
@staticmethod
|
|
345
|
+
def _folders(path: Path) -> tuple[Path, ...]:
|
|
346
|
+
"""Return the directories directly under ``path``, or nothing when it cannot be read."""
|
|
347
|
+
try:
|
|
348
|
+
return tuple(sorted(item for item in path.iterdir() if item.is_dir()))
|
|
349
|
+
except OSError:
|
|
350
|
+
return ()
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _default_root() -> Path:
|
|
354
|
+
"""Return the folder the daemons are recorded under: ``$ZETTCODE_DAEMON_DIR``, or ``~/.zettcode/daemon``."""
|
|
355
|
+
override = os.getenv("ZETTCODE_DAEMON_DIR")
|
|
356
|
+
return Path(override).expanduser() if override else Path.home() / ".zettcode" / "daemon"
|
zettcode/doctor.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Find out whether the configured models work: what ``zettcode doctor`` does.
|
|
2
|
+
|
|
3
|
+
A model entry can be wrong in ways that only show up on the first request: a token that was revoked, a base URL
|
|
4
|
+
with a typo, a model id the endpoint does not serve, an endpoint that speaks the other protocol. The reader finds out
|
|
5
|
+
in the middle of a task. This module asks every configured model one trivial question, all at once, and says for each
|
|
6
|
+
whether it answered and, if it did not, in the provider's own words why not.
|
|
7
|
+
|
|
8
|
+
The question costs a handful of tokens: a one-line prompt at the cheapest reasoning level the entry accepts. Nothing
|
|
9
|
+
is written to a session, no tool is offered, and no extension runs: the request goes to the provider adapter
|
|
10
|
+
directly, so what is measured is the entry and the endpoint and nothing of this program.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import TYPE_CHECKING
|
|
20
|
+
|
|
21
|
+
if TYPE_CHECKING:
|
|
22
|
+
from zettcode.config import ModelConfig, ZettCodeConfig
|
|
23
|
+
|
|
24
|
+
#: Seconds one model may take to answer before it is reported as not answering.
|
|
25
|
+
TIMEOUT = 30.0
|
|
26
|
+
|
|
27
|
+
#: What every model is asked. One short line, so the reply is a token or two.
|
|
28
|
+
QUESTION = "Reply with the single word: ok"
|
|
29
|
+
|
|
30
|
+
#: Characters of a provider's explanation kept in the report; the rest is a page of HTML or a stack of JSON.
|
|
31
|
+
DETAIL_LIMIT = 200
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True, slots=True)
|
|
35
|
+
class Probe:
|
|
36
|
+
"""What asking one model came to.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
model: The entry that was asked.
|
|
40
|
+
ok: Whether it answered.
|
|
41
|
+
seconds: How long the answer, or the failure, took.
|
|
42
|
+
detail: When it answered, the reply (trimmed); when it did not, why.
|
|
43
|
+
tokens: ``(input, output)`` tokens the answer cost, or ``None`` when it did not answer or did not say.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
model: ModelConfig
|
|
47
|
+
ok: bool
|
|
48
|
+
seconds: float
|
|
49
|
+
detail: str = ""
|
|
50
|
+
tokens: tuple[int, int] | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _one_line(text: str) -> str:
|
|
54
|
+
"""Return ``text`` as a single line no longer than :data:`DETAIL_LIMIT`."""
|
|
55
|
+
line = " ".join(text.split())
|
|
56
|
+
return line if len(line) <= DETAIL_LIMIT else line[: DETAIL_LIMIT - 1] + "…"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
async def _ask(model: ModelConfig) -> tuple[str, tuple[int, int] | None]:
|
|
60
|
+
"""Send the question to ``model`` and return its reply and the tokens it cost; raises what the provider raises."""
|
|
61
|
+
from zett_agent.messages import UserMessage
|
|
62
|
+
from zett_agent.model import ModelEventType, ModelRequest
|
|
63
|
+
|
|
64
|
+
from zettcode.engine.core import _build_provider
|
|
65
|
+
|
|
66
|
+
provider = _build_provider(model)
|
|
67
|
+
try:
|
|
68
|
+
request = ModelRequest(messages=[UserMessage(content=QUESTION)], reasoning_effort=model.efforts[0])
|
|
69
|
+
async for event in provider.stream(request):
|
|
70
|
+
if event.type is not ModelEventType.RESPONSE or event.response is None:
|
|
71
|
+
continue
|
|
72
|
+
response = event.response
|
|
73
|
+
stop = response.stop
|
|
74
|
+
if stop is not None and stop.reason.value == "refusal":
|
|
75
|
+
raise RuntimeError(f"the model refused the question: {stop.describe()}")
|
|
76
|
+
content = response.message.content
|
|
77
|
+
reply = content if isinstance(content, str) else "".join(getattr(part, "text", "") for part in content)
|
|
78
|
+
usage = response.usage
|
|
79
|
+
tokens = (usage.input_tokens, usage.output_tokens) if usage.input_tokens or usage.output_tokens else None
|
|
80
|
+
return reply, tokens
|
|
81
|
+
raise RuntimeError("the stream ended without a reply")
|
|
82
|
+
finally:
|
|
83
|
+
await provider.aclose()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def probe(
|
|
87
|
+
model: ModelConfig,
|
|
88
|
+
*,
|
|
89
|
+
timeout: float = TIMEOUT,
|
|
90
|
+
ask: Callable[[ModelConfig], Awaitable[tuple[str, tuple[int, int] | None]]] = _ask,
|
|
91
|
+
) -> Probe:
|
|
92
|
+
"""Ask one model the question and report how it went; never raises.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
model: The entry to ask.
|
|
96
|
+
timeout: Seconds to wait for the answer.
|
|
97
|
+
ask: Sends the question; injectable so a test needs no network.
|
|
98
|
+
"""
|
|
99
|
+
started = time.monotonic()
|
|
100
|
+
try:
|
|
101
|
+
reply, tokens = await asyncio.wait_for(ask(model), timeout)
|
|
102
|
+
except asyncio.TimeoutError:
|
|
103
|
+
return Probe(model, False, time.monotonic() - started, f"no answer within {timeout:g} seconds")
|
|
104
|
+
except Exception as error: # noqa: BLE001 - every kind of failure is an answer to "does it work"
|
|
105
|
+
text = _one_line(str(error)) or type(error).__name__
|
|
106
|
+
return Probe(model, False, time.monotonic() - started, text)
|
|
107
|
+
return Probe(model, True, time.monotonic() - started, _one_line(reply), tokens)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def diagnose(
|
|
111
|
+
models: Sequence[ModelConfig],
|
|
112
|
+
*,
|
|
113
|
+
timeout: float = TIMEOUT,
|
|
114
|
+
ask: Callable[[ModelConfig], Awaitable[tuple[str, tuple[int, int] | None]]] = _ask,
|
|
115
|
+
) -> list[Probe]:
|
|
116
|
+
"""Ask every model at once and return the probes in the order the models are configured."""
|
|
117
|
+
return list(await asyncio.gather(*(probe(model, timeout=timeout, ask=ask) for model in models)))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_report(probes: Sequence[Probe]) -> list[str]:
|
|
121
|
+
"""Return the lines that tell the reader which models work, one per model, and a closing tally."""
|
|
122
|
+
if not probes:
|
|
123
|
+
return ["no models are configured"]
|
|
124
|
+
width = max(len(probe.model.shown_name) for probe in probes)
|
|
125
|
+
lines: list[str] = []
|
|
126
|
+
for item in probes:
|
|
127
|
+
mark = "ok " if item.ok else "FAIL"
|
|
128
|
+
name = item.model.shown_name.ljust(width)
|
|
129
|
+
if item.ok:
|
|
130
|
+
cost = f", {item.tokens[0]} in / {item.tokens[1]} out" if item.tokens else ""
|
|
131
|
+
lines.append(f"{mark} {name} {item.seconds:.1f}s {item.model.api.value}{cost}")
|
|
132
|
+
else:
|
|
133
|
+
lines.append(f"{mark} {name} {item.seconds:.1f}s {item.model.api.value}: {item.detail}")
|
|
134
|
+
failed = sum(not item.ok for item in probes)
|
|
135
|
+
lines.append(
|
|
136
|
+
f"{len(probes)} model{'s' * (len(probes) != 1)} checked: "
|
|
137
|
+
+ ("all answered" if not failed else f"{len(probes) - failed} answered, {failed} did not")
|
|
138
|
+
)
|
|
139
|
+
return lines
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def doctor_program(
|
|
143
|
+
config: ZettCodeConfig,
|
|
144
|
+
*,
|
|
145
|
+
timeout: float = TIMEOUT,
|
|
146
|
+
ask: Callable[[ModelConfig], Awaitable[tuple[str, tuple[int, int] | None]]] = _ask,
|
|
147
|
+
say: Callable[[str], None] = print,
|
|
148
|
+
) -> int:
|
|
149
|
+
"""Check the configured models from the command line: what ``zettcode doctor`` does.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
config: The settings, whose models are asked.
|
|
153
|
+
timeout: Seconds each model may take.
|
|
154
|
+
ask: Sends the question; injectable so a test needs no network.
|
|
155
|
+
say: Where the report goes.
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
``0`` when every model answered, ``1`` when one did not or none is configured.
|
|
159
|
+
"""
|
|
160
|
+
from zettcode.paths import config_path
|
|
161
|
+
from zettcode.version import __version__
|
|
162
|
+
|
|
163
|
+
say(f"ZettCode {__version__} · config {config_path()}")
|
|
164
|
+
probes = asyncio.run(diagnose(config.models, timeout=timeout, ask=ask))
|
|
165
|
+
for line in format_report(probes):
|
|
166
|
+
say(line)
|
|
167
|
+
return 0 if probes and all(item.ok for item in probes) else 1
|