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/sdk/types.py ADDED
@@ -0,0 +1,907 @@
1
+ """The plain values the SDK hands back.
2
+
3
+ Every one is a frozen dataclass owned by the SDK, not a view of an internal class, so the program does not break
4
+ when the application reorganizes its inside, and every one can be built by hand in a test. They carry no behaviour
5
+ beyond arithmetic on their own fields.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import mimetypes
11
+ from collections.abc import Mapping, Sequence
12
+ from dataclasses import dataclass, field, fields
13
+ from datetime import date, datetime
14
+ from pathlib import Path
15
+ from typing import TYPE_CHECKING, Any, Literal, TypeAlias
16
+
17
+ from zettcode.modes import MODE_STEER
18
+
19
+ if TYPE_CHECKING: # pragma: no cover - annotations only
20
+ from .events import Event
21
+
22
+ #: How one request may use the provider's prompt cache. ``"read_write"`` (the default) extends the cached prefix,
23
+ #: ``"read"`` reuses a warm one without marking this request's own content for caching, and ``"off"`` sends no cache
24
+ #: directive at all. What a provider can honour is its own: one that caches automatically has no client-side write
25
+ #: control, so ``"read"`` and ``"read_write"`` are the same there and ``"off"`` only drops a routing key.
26
+ CacheMode: TypeAlias = Literal["off", "read", "read_write"]
27
+
28
+ __all__ = [
29
+ "CacheMode",
30
+ "DIRECT_EMPTY",
31
+ "DIRECT_FAILED",
32
+ "DIRECT_UNFINISHED",
33
+ "Compaction",
34
+ "ContextReport",
35
+ "ContextShare",
36
+ "DaemonInfo",
37
+ "DayTotals",
38
+ "Delivered",
39
+ "Image",
40
+ "IncomingMessage",
41
+ "MentionInfo",
42
+ "Message",
43
+ "ModelCheck",
44
+ "ModelInfo",
45
+ "ModelTotals",
46
+ "Peer",
47
+ "PluginEntry",
48
+ "Receipt",
49
+ "Relay",
50
+ "ResumeInfo",
51
+ "RunResult",
52
+ "SendLimits",
53
+ "SessionInfo",
54
+ "SessionStatsInfo",
55
+ "SessionTotals",
56
+ "StoredSession",
57
+ "StreamEvent",
58
+ "TaskInfo",
59
+ "ToolCallInfo",
60
+ "ToolRecord",
61
+ "Totals",
62
+ "Usage",
63
+ "WorkspaceUsage",
64
+ "brief_of",
65
+ "is_direct_notice",
66
+ ]
67
+
68
+ #: Media types a model accepts as an image; anything else is rejected before it reaches the provider.
69
+ IMAGE_TYPES = frozenset({"image/png", "image/jpeg", "image/gif", "image/webp"})
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class Usage:
74
+ """Token counters, for one run or for a whole session.
75
+
76
+ ``input_tokens`` already contains the cache reads and writes, and ``output_tokens`` already contains the
77
+ reasoning, so :attr:`total_tokens` is the plain sum and nothing is counted twice.
78
+
79
+ Attributes:
80
+ input_tokens: Everything sent to the model.
81
+ output_tokens: Everything the model generated.
82
+ cache_read_tokens: Input the provider served from its cache.
83
+ cache_write_tokens: Input written into the provider's cache.
84
+ reasoning_tokens: The reasoning part of ``output_tokens``.
85
+ seconds: Time from the first token of each call to that call's terminal response, summed: the window output
86
+ was generated in, the rate's denominator.
87
+ requests: How many model calls were made.
88
+ context_tokens: How full the context was on the newest call; for a delta, the value after the run.
89
+ """
90
+
91
+ input_tokens: int = 0
92
+ output_tokens: int = 0
93
+ cache_read_tokens: int = 0
94
+ cache_write_tokens: int = 0
95
+ reasoning_tokens: int = 0
96
+ seconds: float = 0.0
97
+ requests: int = 0
98
+ context_tokens: int = 0
99
+
100
+ @property
101
+ def total_tokens(self) -> int:
102
+ """Return input plus output."""
103
+ return self.input_tokens + self.output_tokens
104
+
105
+ @property
106
+ def cache_hit_rate(self) -> float | None:
107
+ """Return the cached share of the input, or ``None`` before there is any input."""
108
+ return self.cache_read_tokens / self.input_tokens if self.input_tokens else None
109
+
110
+ @property
111
+ def output_rate(self) -> float | None:
112
+ """Return generated tokens per second of generation time, or ``None`` when it was not measured.
113
+
114
+ The numerator is the provider's own count and the denominator is the time from the first token to the end of
115
+ the response. Counting the tokens that arrived with the first chunk against a window that opens at that chunk
116
+ reads a little high, which is the trade for one number that no program-side clock can understate.
117
+ """
118
+ return self.output_tokens / self.seconds if self.seconds > 0 and self.output_tokens > 0 else None
119
+
120
+ def since(self, earlier: Usage) -> Usage:
121
+ """Return what was spent after ``earlier``: the counters subtracted, never below zero.
122
+
123
+ ``context_tokens`` is a level, not a counter, so it is this usage's own. The subtraction is clamped because
124
+ a session switched or replayed between the two readings can make a counter go backwards, and a negative
125
+ spend is never a true statement.
126
+ """
127
+ values = {
128
+ item.name: max(0, getattr(self, item.name) - getattr(earlier, item.name))
129
+ for item in fields(self)
130
+ if item.name not in ("context_tokens", "seconds")
131
+ }
132
+ return Usage(
133
+ **values,
134
+ seconds=max(0.0, self.seconds - earlier.seconds),
135
+ context_tokens=self.context_tokens,
136
+ )
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class Image:
141
+ """An image to send with a prompt.
142
+
143
+ Attributes:
144
+ data: The encoded file, not decoded pixels.
145
+ media_type: ``image/png``, ``image/jpeg``, ``image/gif`` or ``image/webp``.
146
+ """
147
+
148
+ data: bytes
149
+ media_type: str
150
+
151
+ def __post_init__(self) -> None:
152
+ """Reject what no model would accept, so the error is here and not a provider's 400."""
153
+ if not isinstance(self.data, (bytes, bytearray)) or not self.data:
154
+ raise ValueError("An image needs its encoded bytes")
155
+ if self.media_type not in IMAGE_TYPES:
156
+ raise ValueError(
157
+ f"Unsupported image type {self.media_type!r}; use one of: {', '.join(sorted(IMAGE_TYPES))}"
158
+ )
159
+
160
+ @classmethod
161
+ def from_path(cls, path: str | Path) -> Image:
162
+ """Read an image file, taking the media type from its extension.
163
+
164
+ Raises:
165
+ ValueError: The extension is not an image type a model takes, or the file is empty.
166
+ OSError: The file cannot be read.
167
+ """
168
+ file = Path(path)
169
+ media_type = mimetypes.guess_type(file.name)[0] or ""
170
+ return cls(file.read_bytes(), media_type)
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class ModelInfo:
175
+ """One configured model.
176
+
177
+ Attributes:
178
+ name: What to pass to ``use_model``: the display name when the entry has one, else the model id.
179
+ model: The model id sent to the endpoint.
180
+ context_window: Tokens it can carry.
181
+ multimodal: Whether it accepts images.
182
+ efforts: The reasoning levels it accepts, cheapest first.
183
+ active: Whether it is the model the next run uses.
184
+ compact_percent: Share of the window, in percent, at which a request is summarized first (80 by default).
185
+ web_search: Whether the provider searches the web for this model.
186
+ web_search_name: The tool type that search is sent as, when the entry names one; ``None`` takes the default
187
+ for the API it speaks.
188
+ display_model: The name the picker shows, when the entry has one.
189
+ base_url: The address requests go to, or ``None`` for the API's own.
190
+ api: The protocol the entry speaks: ``chat``, ``responses`` or ``anthropic``.
191
+ temperature: The sampling temperature the entry sends, or ``None`` for the provider's own.
192
+ can_search: Whether the endpoint serves hosted web search at all (DeepSeek's chat endpoint does not).
193
+ search_name: The tool type search is sent as: the entry's own, or the default for the API it speaks.
194
+ """
195
+
196
+ name: str
197
+ model: str
198
+ context_window: int
199
+ multimodal: bool
200
+ efforts: tuple[str, ...]
201
+ active: bool = False
202
+ compact_percent: float = 80.0
203
+ web_search: bool = False
204
+ web_search_name: str | None = None
205
+ display_model: str | None = None
206
+ base_url: str | None = None
207
+ api: str = "chat"
208
+ temperature: float | None = None
209
+ can_search: bool = True
210
+ search_name: str = ""
211
+
212
+ @property
213
+ def compaction_max_tokens(self) -> int:
214
+ """Return the context size, in tokens, at which a request is summarized first."""
215
+ return max(1, round(self.context_window * self.compact_percent / 100))
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class SessionInfo:
220
+ """One stored session, as a list shows it.
221
+
222
+ Attributes:
223
+ id: The session's identity; pass it to ``sessions.open``.
224
+ title: Its name, or ``None`` until one was written.
225
+ created_at: When its first record was written.
226
+ updated_at: When its conversation last changed.
227
+ """
228
+
229
+ id: str
230
+ title: str | None
231
+ created_at: datetime
232
+ updated_at: datetime
233
+
234
+
235
+ @dataclass(frozen=True, slots=True)
236
+ class StoredSession:
237
+ """A conversation read from the store and not yet current: what :meth:`Sessions.read` returns.
238
+
239
+ Attributes:
240
+ id: The session's identity.
241
+ title: Its name, or ``None`` until one was written.
242
+ model: The model it last used, by the name ``models`` lists, or ``None`` when none was recorded.
243
+ usage: What it has spent in total, from the replies the file records.
244
+ records: The parsed conversation file, for a front end that draws the conversation itself. Its type belongs to
245
+ the engine and is not part of this API: read it with the helper of the front end that draws it, and do not
246
+ depend on its shape.
247
+ """
248
+
249
+ id: str
250
+ title: str | None
251
+ model: str | None
252
+ usage: Usage
253
+ records: object = field(repr=False, compare=False)
254
+ _engine: Any = field(default=None, repr=False, compare=False)
255
+
256
+
257
+ @dataclass(frozen=True, slots=True)
258
+ class TaskInfo:
259
+ """One step of the plan the model keeps for a session.
260
+
261
+ Attributes:
262
+ status: ``pending``, ``in_progress`` or ``completed``.
263
+ content: What the step says.
264
+ """
265
+
266
+ status: str
267
+ content: str
268
+
269
+
270
+ @dataclass(frozen=True, slots=True)
271
+ class ToolCallInfo:
272
+ """A tool call the model made.
273
+
274
+ Attributes:
275
+ id: Identity that ties the call to its result.
276
+ name: The tool.
277
+ arguments: What it was called with.
278
+ """
279
+
280
+ id: str
281
+ name: str
282
+ arguments: dict[str, object]
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class ToolRecord:
287
+ """A tool call and how it ended, as :class:`RunResult` lists them.
288
+
289
+ Attributes:
290
+ id: Identity of the call.
291
+ name: The tool.
292
+ arguments: What it was called with.
293
+ output: What it returned, or the failure text.
294
+ ok: Whether it ran to success; a skipped call is not.
295
+ skipped: Whether it never ran because steering took over.
296
+ """
297
+
298
+ id: str
299
+ name: str
300
+ arguments: dict[str, object]
301
+ output: str
302
+ ok: bool
303
+ skipped: bool = False
304
+
305
+
306
+ @dataclass(frozen=True, slots=True)
307
+ class Message:
308
+ """One message of a stored conversation.
309
+
310
+ Attributes:
311
+ role: ``user``, ``assistant`` or ``tool``; ``summary`` for the text a compaction left in place of the
312
+ older messages, which nobody typed.
313
+ text: What was said. For a user message it is what the person typed, not the instructions an ``@``
314
+ reference added behind it.
315
+ tool_calls: For an assistant message, the tools it asked for.
316
+ tool_call_id: For a tool message, the call it answers.
317
+ tool_name: For a tool message, the tool.
318
+ ok: For a tool message, whether the call succeeded.
319
+ images: How many images the message carries.
320
+ pictures: For a user message, the images themselves, so a client that reopens the session can show them
321
+ again. A tool message's images are counted but not returned.
322
+ usage: For an assistant message, what its model call spent, when the store recorded it.
323
+ seconds: How long the message took to produce: the model call for an assistant message, the run for a tool
324
+ message, and nothing for a user message.
325
+ relay: For a user message that another session sent, who sent it; ``None`` for what the reader typed.
326
+ """
327
+
328
+ role: str
329
+ text: str
330
+ tool_calls: tuple[ToolCallInfo, ...] = ()
331
+ tool_call_id: str | None = None
332
+ tool_name: str | None = None
333
+ ok: bool = True
334
+ images: int = 0
335
+ usage: Usage | None = None
336
+ seconds: float = 0.0
337
+ pictures: tuple[Image, ...] = ()
338
+ relay: Relay | None = None
339
+
340
+
341
+ @dataclass(frozen=True, slots=True)
342
+ class Relay:
343
+ """Who sent a message that came from another session, as the conversation stored it.
344
+
345
+ Attributes:
346
+ sender_id: The sending session's id; the stable way to tell senders apart, because a handle can change.
347
+ sender: The sender's handle when it sent the message.
348
+ via: ``"user"`` when its reader typed it, ``"agent"`` when its agent sent it, ``"external"`` when the sender
349
+ was a program that is not a ZettCode session.
350
+ hops: How many sessions the message had passed through.
351
+ """
352
+
353
+ sender_id: str
354
+ sender: str
355
+ via: str = "agent"
356
+ hops: int = 1
357
+
358
+
359
+ @dataclass(frozen=True, slots=True)
360
+ class Peer:
361
+ """Another session that is registered with the broker right now.
362
+
363
+ Attributes:
364
+ session_id: The session's id; what a message is addressed to.
365
+ handle: The word that follows an ``@`` to address it.
366
+ title: What the session is about, or an empty string before it has a name.
367
+ workspace: The directory it works in.
368
+ status: ``"idle"`` or ``"working"``.
369
+ kind: ``"zettcode"`` for a session, ``"external"`` for a program that stands in for one.
370
+ direct: Whether it runs a message sent with ``direct=True`` as its reader's own prompt.
371
+ """
372
+
373
+ session_id: str
374
+ handle: str
375
+ title: str = ""
376
+ workspace: str = ""
377
+ status: str = "idle"
378
+ kind: str = "zettcode"
379
+ direct: bool = False
380
+
381
+
382
+ #: What a session sends back for a turn that did not end in an answer, so the sender is not left waiting.
383
+ #: They are shared by the sessions that send them and the gateway that shows them as notes rather than as replies.
384
+ DIRECT_FAILED = "The turn failed: "
385
+ DIRECT_UNFINISHED = "(the turn did not finish)"
386
+ DIRECT_EMPTY = "(the turn ended without a written answer)"
387
+
388
+
389
+ def is_direct_notice(text: str) -> bool:
390
+ """Return whether a message is one of the lines a session sends when a turn ended without an answer."""
391
+ return text.startswith(DIRECT_FAILED) or text in (DIRECT_UNFINISHED, DIRECT_EMPTY)
392
+
393
+
394
+ #: The arguments that say what a tool call is about, in the order they are looked for.
395
+ BRIEF_KEYS = ("command", "path", "file_path", "pattern", "query", "url", "to", "name", "description")
396
+
397
+
398
+ def brief_of(arguments: Mapping[str, object]) -> str:
399
+ """Return one line saying what a tool call was about: its command, path, pattern or the like."""
400
+ for key in BRIEF_KEYS:
401
+ value = arguments.get(key)
402
+ if isinstance(value, str) and value.strip():
403
+ line = " ".join(value.split())
404
+ return line if len(line) <= 120 else line[:117] + "..."
405
+ return ""
406
+
407
+
408
+ @dataclass(frozen=True, slots=True)
409
+ class StreamEvent:
410
+ """One thing a watched session's running turn said, as it happened.
411
+
412
+ Attributes:
413
+ session_id: The session whose turn it is.
414
+ kind: ``"begin"`` (a turn started), ``"text"`` (more of the answer), ``"thinking"`` (the model is reasoning),
415
+ ``"tool"`` (a tool started), ``"tool_end"`` (it ended), ``"end"`` (the turn ended; what it produced is in
416
+ the stored conversation).
417
+ text: For ``"text"``, the new piece of the answer.
418
+ call_id: For ``"tool"`` and ``"tool_end"``, which call.
419
+ name: For ``"tool"``, the tool.
420
+ brief: For ``"tool"``, one line saying what it was called on.
421
+ ok: For ``"tool_end"``, whether it succeeded.
422
+ """
423
+
424
+ session_id: str
425
+ kind: str
426
+ text: str = ""
427
+ call_id: str = ""
428
+ name: str = ""
429
+ brief: str = ""
430
+ ok: bool | None = None
431
+
432
+
433
+ @dataclass(frozen=True, slots=True)
434
+ class SendLimits:
435
+ """What the broker accepts from a sender, so a front end can refuse before it sends.
436
+
437
+ Attributes:
438
+ max_text: The longest message, in characters.
439
+ rate_limit: Messages one sender may send in ``rate_window`` seconds.
440
+ rate_window: The length of that window, in seconds.
441
+ max_hops: How many sessions a chain of agent messages may pass through.
442
+ max_images: How many pictures one message may carry.
443
+ max_image_bytes: The largest one picture, in bytes of the encoded file.
444
+ max_images_bytes: The most the pictures of one message may weigh together.
445
+ """
446
+
447
+ max_text: int = 32_000
448
+ rate_limit: int = 20
449
+ rate_window: float = 60.0
450
+ max_hops: int = 6
451
+ max_images: int = 4
452
+ max_image_bytes: int = 4 << 20
453
+ max_images_bytes: int = 8 << 20
454
+
455
+
456
+ @dataclass(frozen=True, slots=True)
457
+ class DaemonInfo:
458
+ """One background agent, as ``daemon list`` shows it.
459
+
460
+ Attributes:
461
+ session_id: The session it runs, which is also the id other sessions message it by.
462
+ workspace: The directory it works in, or an empty string when it never recorded one.
463
+ status: ``starting``, ``idle``, ``working``, ``stopping``, ``dead`` (its process is gone) or ``unknown``.
464
+ name: The label its starter gave it, saying what it is for.
465
+ title: The session's own name, once the model has written one.
466
+ model: The model it runs on.
467
+ effort: The reasoning level it runs at, or ``None`` for the model's own.
468
+ permission: The shell level: ``deny``, ``safe`` or ``full``.
469
+ pid: Its process id, or ``0`` when it never recorded one.
470
+ started_at: Seconds since the epoch when it started, or ``0.0``.
471
+ input_tokens: Everything its session has sent to the model, or ``None`` when it has recorded none.
472
+ output_tokens: Everything the model has generated for it, or ``None``.
473
+ context_tokens: How full its context was on the newest call, or ``None``.
474
+ context_window: The window of the model it runs on, or ``None``.
475
+ requested_model: A model a reader asked for, not yet in force, or ``None``.
476
+ requested_effort: A reasoning level a reader asked for, not yet in force, or ``None``.
477
+ requested_error: Why the last requested model or level could not be put in force, or ``None``.
478
+ broker: Whether the session broker was reachable at the last check, or ``None`` before one.
479
+ """
480
+
481
+ session_id: str
482
+ workspace: str = ""
483
+ status: str = "unknown"
484
+ name: str | None = None
485
+ title: str | None = None
486
+ model: str | None = None
487
+ effort: str | None = None
488
+ permission: str | None = None
489
+ pid: int = 0
490
+ started_at: float = 0.0
491
+ input_tokens: int | None = None
492
+ output_tokens: int | None = None
493
+ context_tokens: int | None = None
494
+ context_window: int | None = None
495
+ requested_model: str | None = None
496
+ requested_effort: str | None = None
497
+ requested_error: str | None = None
498
+ broker: bool | None = None
499
+
500
+
501
+ @dataclass(frozen=True, slots=True)
502
+ class MentionInfo:
503
+ """One ``@`` resource a prompt can reference, as the completion menu lists it.
504
+
505
+ Attributes:
506
+ token: The text as typed, ``@name``.
507
+ kind: What owns it: ``"file"``, ``"skill"``, or the name a plugin gives its own kind.
508
+ name: The resource without the ``@``; a file's path is relative to the workspace.
509
+ description: One line for a menu.
510
+ """
511
+
512
+ token: str
513
+ kind: str
514
+ name: str
515
+ description: str
516
+
517
+
518
+ @dataclass(frozen=True, slots=True)
519
+ class PluginEntry:
520
+ """One plugin as a list of them shows it, and as a switch is written for it.
521
+
522
+ Attributes:
523
+ name: What the plugin calls itself.
524
+ origin: The distribution that carries it, which is the name a switch is written with; the same as ``name``
525
+ for a plugin compiled into the program.
526
+ builtin: Whether it is compiled into the program.
527
+ enabled: Whether the next start does its work. A switch is a promise about that start, not about this process.
528
+ failure: Why it could not load here, or ``""`` when it did.
529
+ setting: The configuration key that decides a compiled-in plugin (``"skills.enabled"``), or ``""`` for an
530
+ installed one, which `[plugins] disable` holds.
531
+ switchable: Whether there is a switch to draw here; a list shows the rest without one and says ``why``.
532
+ why: Why it cannot be switched, when it cannot; ``""`` otherwise.
533
+ """
534
+
535
+ name: str
536
+ origin: str
537
+ builtin: bool = False
538
+ enabled: bool = True
539
+ failure: str = ""
540
+ setting: str = ""
541
+ switchable: bool = True
542
+ why: str = ""
543
+
544
+
545
+ @dataclass(frozen=True, slots=True)
546
+ class IncomingMessage:
547
+ """A message another ZettCode session sent, as the broker delivered it.
548
+
549
+ Attributes:
550
+ sender_id: The sending session's id; the stable way to answer it, because a handle can change.
551
+ sender: The sender's handle when it sent the message.
552
+ text: What it said.
553
+ via: ``"user"`` when its reader typed it, ``"agent"`` when its agent sent it, ``"external"`` when the
554
+ sender is a program that is not a ZettCode session.
555
+ hops: How many sessions the message has passed through; an agent's answer carries it on, which ends two
556
+ agents that keep answering each other.
557
+ direct: ``True`` when the sender is a person's one-to-one chat: run ``text`` with :meth:`Session.prompt`, as
558
+ the session's own reader's words, and give the final answer back with :meth:`Client.send_back`.
559
+ mode: ``"steer"`` (the default) asks this session to hand the message to the request that is running, and
560
+ ``"followup"`` asks it to wait until that request ends. An idle session starts a turn either way. A
561
+ front end that cannot steer a running request — a polled inbox — holds it as a follow-up and answers
562
+ ``queued``.
563
+ images: The pictures a ``direct`` message carries, to put into the prompt with the text.
564
+ batch: When several messages that waited were merged into this one (see :meth:`merged`), the messages
565
+ themselves, oldest first. ``text`` is then their words in order, for a log or a title; the model is given
566
+ each under its own line saying who sent it.
567
+ """
568
+
569
+ sender_id: str
570
+ sender: str
571
+ text: str
572
+ via: str = "agent"
573
+ hops: int = 1
574
+ direct: bool = False
575
+ mode: str = MODE_STEER
576
+ group: str = ""
577
+ images: tuple[Image, ...] = ()
578
+ batch: tuple[IncomingMessage, ...] = ()
579
+
580
+ @classmethod
581
+ def merged(cls, items: Sequence[IncomingMessage]) -> IncomingMessage:
582
+ """Return one message that stands for ``items``, oldest first, to be run as a single turn.
583
+
584
+ A chat window's message (``direct``) is answered by the turn it starts, so it is never merged: pass it alone.
585
+ One message is returned as it is.
586
+ """
587
+ if len(items) == 1:
588
+ return items[0]
589
+ senders = list(dict.fromkeys(item.sender for item in items))
590
+ return cls(
591
+ sender_id=items[0].sender_id,
592
+ sender=", ".join(senders),
593
+ text="\n\n".join(f"{item.sender}: {item.text}" for item in items),
594
+ via="agent" if any(item.via != "user" for item in items) else "user",
595
+ hops=max(item.hops for item in items),
596
+ batch=tuple(items),
597
+ )
598
+
599
+
600
+ @dataclass(frozen=True, slots=True)
601
+ class Receipt:
602
+ """What a program says about a message another session sent it: the answer to the sender.
603
+
604
+ Attributes:
605
+ accepted: Whether it took the message. A refusal carries its reason to the sender.
606
+ reason: Why not, in words the sender's model can act on.
607
+ disposition: What will happen to it: ``"started"`` (a turn began), ``"steered"`` (the request that was
608
+ running took it into account), ``"queued"`` (it waits for the current request to end), or
609
+ ``"delivered"`` (a chat window kept it without starting a turn).
610
+ busy: With ``accepted`` false: the inbox is full right now, which will pass. The broker keeps such a message
611
+ and offers it again when there is room, instead of telling the sender it was refused.
612
+ """
613
+
614
+ accepted: bool
615
+ reason: str = ""
616
+ disposition: str = ""
617
+ busy: bool = False
618
+
619
+
620
+ @dataclass(frozen=True, slots=True)
621
+ class Delivered:
622
+ """The answer to a send: who took the message, and what they will do with it.
623
+
624
+ Attributes:
625
+ to: The handle of the session that took it.
626
+ disposition: ``"started"`` (a turn began there) or ``"queued"`` (it waits for the request running there).
627
+ direct: Whether the broker handed it over as the session's own prompt (see ``Client.send``'s ``direct``).
628
+ """
629
+
630
+ to: str
631
+ disposition: str
632
+ direct: bool = False
633
+
634
+
635
+ @dataclass(frozen=True, slots=True)
636
+ class ContextShare:
637
+ """One source's share of the context.
638
+
639
+ Attributes:
640
+ name: The source, such as ``Tool output``.
641
+ tokens: Tokens it contributes.
642
+ items: Messages, tools or summaries counted into ``tokens``.
643
+ """
644
+
645
+ name: str
646
+ tokens: int
647
+ items: int = 0
648
+
649
+
650
+ @dataclass(frozen=True, slots=True)
651
+ class ContextReport:
652
+ """What the next request would carry, and how full the context is.
653
+
654
+ Attributes:
655
+ window: Tokens the model can carry.
656
+ used: Tokens the request carries: the provider's own count when it gave one, else the tokenizer's.
657
+ measured_by: The counter behind ``shares``.
658
+ shares: One entry per non-empty source.
659
+ partial: Whether a part of the request could not be measured yet, so ``used`` reads low.
660
+ from_provider: Whether ``used`` is the provider's count.
661
+ """
662
+
663
+ window: int
664
+ used: int
665
+ measured_by: str
666
+ shares: tuple[ContextShare, ...] = ()
667
+ partial: bool = False
668
+ from_provider: bool = False
669
+
670
+ @property
671
+ def free(self) -> int:
672
+ """Return the tokens left, never negative."""
673
+ return max(0, self.window - self.used)
674
+
675
+ @property
676
+ def fraction(self) -> float:
677
+ """Return how full the context is, from 0 up to 1 (more only if the count exceeds the window)."""
678
+ return self.used / self.window if self.window > 0 else 0.0
679
+
680
+ @property
681
+ def measured(self) -> int:
682
+ """Return the tokens the tokenizer counted, which ``shares`` add up to (``used`` may be the provider's own)."""
683
+ return sum(share.tokens for share in self.shares)
684
+
685
+ @property
686
+ def estimated(self) -> bool:
687
+ """Return whether ``shares`` are a characters-per-token estimate and not a tokenizer's count."""
688
+ return "chars/token" in self.measured_by
689
+
690
+ def percent(self, tokens: int) -> float:
691
+ """Return ``tokens`` as a share of the window, in percent: how full the model's window is."""
692
+ return tokens / self.window * 100 if self.window else 0.0
693
+
694
+ def share(self, tokens: int) -> float:
695
+ """Return ``tokens`` as a share of what the tokenizer counted, in percent: where the context goes.
696
+
697
+ It divides by :attr:`measured` and never by a provider's total for the same request, which would leave the
698
+ rows short of a hundred.
699
+ """
700
+ return tokens / self.measured * 100 if self.measured else 0.0
701
+
702
+
703
+ @dataclass(frozen=True, slots=True)
704
+ class Compaction:
705
+ """A conversation summarized to make room.
706
+
707
+ Attributes:
708
+ summary: The text that now stands in for the older messages.
709
+ compressed: How many messages were folded into it.
710
+ kept: How many were kept as they were.
711
+ """
712
+
713
+ summary: str
714
+ compressed: int
715
+ kept: int
716
+
717
+
718
+ @dataclass(frozen=True, slots=True)
719
+ class ResumeInfo:
720
+ """Where an interrupted request stopped and what continuing would do.
721
+
722
+ Attributes:
723
+ kind: How it was interrupted.
724
+ description: One sentence for a person.
725
+ """
726
+
727
+ kind: str
728
+ description: str
729
+
730
+
731
+ @dataclass(frozen=True, slots=True)
732
+ class RunResult:
733
+ """What a finished run came to.
734
+
735
+ Attributes:
736
+ text: The model's final answer.
737
+ session_id: The session the run belongs to.
738
+ usage: What the run spent, not the session's total.
739
+ tools: Every tool call the run made, in order, with how each ended.
740
+ stop: ``None`` when the model finished normally, else why it stopped (``refusal``, ``max_output``, ...).
741
+ stop_message: The provider's sentence about a stop, when it gave one.
742
+ questions: The questions the model asked, in order.
743
+ events: Every event of the run, when it was asked to keep them.
744
+ """
745
+
746
+ text: str
747
+ session_id: str
748
+ usage: Usage = field(default_factory=Usage)
749
+ tools: tuple[ToolRecord, ...] = ()
750
+ stop: str | None = None
751
+ stop_message: str | None = None
752
+ questions: tuple[str, ...] = ()
753
+ events: tuple[Event, ...] = ()
754
+
755
+ @property
756
+ def ok(self) -> bool:
757
+ """Return whether the model finished on its own: no refusal, cut-off or interruption."""
758
+ return self.stop is None
759
+
760
+ @property
761
+ def failed_tools(self) -> tuple[ToolRecord, ...]:
762
+ """Return the tool calls that did not succeed."""
763
+ return tuple(tool for tool in self.tools if not tool.ok)
764
+
765
+
766
+ @dataclass(frozen=True, slots=True)
767
+ class Totals:
768
+ """Token counters added up over some replies: what the usage ledger reports.
769
+
770
+ A tally is read back from the stored conversations, so it has no wall-clock seconds and no context size — those
771
+ belong to a call, not to a record. :attr:`Usage` is what one session or run spent now.
772
+
773
+ Attributes:
774
+ requests: Model replies counted.
775
+ input_tokens: Everything sent, cache reads included.
776
+ output_tokens: Everything generated, reasoning included.
777
+ cache_read_tokens: The part of the input the provider served from its cache.
778
+ reasoning_tokens: The reasoning part of the output.
779
+ """
780
+
781
+ requests: int = 0
782
+ input_tokens: int = 0
783
+ output_tokens: int = 0
784
+ cache_read_tokens: int = 0
785
+ reasoning_tokens: int = 0
786
+
787
+ @property
788
+ def total_tokens(self) -> int:
789
+ """Return everything sent plus everything generated."""
790
+ return self.input_tokens + self.output_tokens
791
+
792
+ @property
793
+ def cache_share(self) -> float | None:
794
+ """Return the share of the input the provider served from its cache, or ``None`` before there is input."""
795
+ return self.cache_read_tokens / self.input_tokens if self.input_tokens else None
796
+
797
+
798
+ @dataclass(frozen=True, slots=True)
799
+ class SessionTotals:
800
+ """One session in the workspace's ledger: what it spent, and how long it is.
801
+
802
+ Attributes:
803
+ id: The session's identity.
804
+ title: Its name, or ``None`` until one was written.
805
+ started: When its first record was written.
806
+ updated: When its conversation last changed.
807
+ tokens: What it spent.
808
+ models: The models it was charged under, in the order they were used.
809
+ entries: Message records the conversation file holds.
810
+ """
811
+
812
+ id: str
813
+ title: str | None
814
+ started: datetime
815
+ updated: datetime
816
+ tokens: Totals
817
+ models: tuple[str, ...]
818
+ entries: int = 0
819
+
820
+
821
+ @dataclass(frozen=True, slots=True)
822
+ class ModelTotals:
823
+ """What one model was charged across the workspace."""
824
+
825
+ model: str
826
+ tokens: Totals
827
+
828
+
829
+ @dataclass(frozen=True, slots=True)
830
+ class DayTotals:
831
+ """What one day spent across the workspace."""
832
+
833
+ day: date
834
+ tokens: Totals
835
+
836
+
837
+ @dataclass(frozen=True, slots=True)
838
+ class WorkspaceUsage:
839
+ """What a workspace's stored sessions have spent, as the ``/usage`` page shows it.
840
+
841
+ Attributes:
842
+ sessions: One entry per session, most recently active first.
843
+ models: One entry per model, busiest first.
844
+ days: One entry per day that spent anything, oldest first.
845
+ total: Everything together.
846
+ """
847
+
848
+ sessions: tuple[SessionTotals, ...]
849
+ models: tuple[ModelTotals, ...]
850
+ days: tuple[DayTotals, ...]
851
+ total: Totals
852
+
853
+
854
+ @dataclass(frozen=True, slots=True)
855
+ class SessionStatsInfo:
856
+ """The totals one row of the ``/resume`` panel shows, read from the store.
857
+
858
+ Attributes:
859
+ id: The session.
860
+ entries: Message records its conversation holds.
861
+ requests: Model replies it was charged for; a fork is not charged for the replies it copied.
862
+ input_tokens: Everything sent, cache reads included.
863
+ output_tokens: Everything generated, reasoning included.
864
+ cache_read_tokens: The part of the input the provider served from its cache.
865
+ reasoning_tokens: The reasoning part of the output.
866
+ """
867
+
868
+ id: str
869
+ entries: int = 0
870
+ requests: int = 0
871
+ input_tokens: int = 0
872
+ output_tokens: int = 0
873
+ cache_read_tokens: int = 0
874
+ reasoning_tokens: int = 0
875
+
876
+ @property
877
+ def total_tokens(self) -> int:
878
+ """Return everything sent plus everything generated."""
879
+ return self.input_tokens + self.output_tokens
880
+
881
+ @property
882
+ def cache_share(self) -> float | None:
883
+ """Return the cached share of the input, or ``None`` before there was any."""
884
+ return self.cache_read_tokens / self.input_tokens if self.input_tokens else None
885
+
886
+
887
+ @dataclass(frozen=True, slots=True)
888
+ class ModelCheck:
889
+ """One configured model, asked one short question by :meth:`~zettcode.sdk.Client.check_models`.
890
+
891
+ Attributes:
892
+ model: The name the configuration shows it by.
893
+ model_id: The id sent to the endpoint.
894
+ ok: Whether it answered.
895
+ seconds: How long the answer, or the failure, took.
896
+ detail: The reply when it answered; the provider's reason when it did not.
897
+ input_tokens: What the answer cost, when the provider said.
898
+ output_tokens: What it generated, when the provider said.
899
+ """
900
+
901
+ model: str
902
+ model_id: str
903
+ ok: bool
904
+ seconds: float
905
+ detail: str = ""
906
+ input_tokens: int | None = None
907
+ output_tokens: int | None = None