conferllm 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.
conferllm/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """ConferLLM - Unified local access to configured AI models."""
2
+
3
+ from .chat import ChatResult, ChatService
4
+ from .client import LLMClient
5
+ from .config import ConferLLMConfig, ModelConfig
6
+ from .session import LoadedSession, SessionMetadata, SessionStore
7
+
8
+ __all__ = [
9
+ "ConferLLMConfig",
10
+ "ChatResult",
11
+ "ChatService",
12
+ "LLMClient",
13
+ "LoadedSession",
14
+ "ModelConfig",
15
+ "SessionMetadata",
16
+ "SessionStore",
17
+ "__version__",
18
+ ]
19
+
20
+ __version__ = "0.1.0"
conferllm/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Run ConferLLM with ``python -m conferllm``."""
2
+
3
+ from .cli import main
4
+
5
+ main()
conferllm/artifacts.py ADDED
@@ -0,0 +1,502 @@
1
+ """Session-owned image artifacts and per-turn storage transactions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import os
7
+ import re
8
+ import shutil
9
+ import stat
10
+ import tempfile
11
+ from contextlib import suppress
12
+ from dataclasses import dataclass
13
+ from pathlib import Path, PurePosixPath
14
+ from typing import Any, Literal
15
+
16
+ from .images import MIME_EXTENSIONS
17
+
18
+ ArtifactDirection = Literal["input", "output"]
19
+
20
+ ARTIFACT_ID_PATTERN = re.compile(
21
+ r"^t(?P<turn>\d{4})-(?P<direction>input|output)-(?P<number>\d{3})$"
22
+ )
23
+ SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
24
+ SESSION_ID_PATTERN = re.compile(r"^\d{8}-[0-9a-f]{32}$")
25
+
26
+
27
+ class ArtifactError(ValueError):
28
+ """Raised when artifact metadata or storage is invalid."""
29
+
30
+ def __init__(self, message: str, *, code: str = "storage_error") -> None:
31
+ super().__init__(message)
32
+ self.code = code
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class Artifact:
37
+ """Immutable metadata for one session-owned image."""
38
+
39
+ id: str
40
+ direction: ArtifactDirection
41
+ index: int
42
+ mime_type: str
43
+ size_bytes: int
44
+ sha256: str
45
+ relative_path: str
46
+ kind: str = "image"
47
+
48
+ def __post_init__(self) -> None:
49
+ """Validate all persisted fields and their cross-field relationships."""
50
+ if not isinstance(self.id, str):
51
+ raise ArtifactError("Artifact ID must be a string.")
52
+ match = ARTIFACT_ID_PATTERN.fullmatch(self.id)
53
+ if match is None:
54
+ raise ArtifactError(f"Invalid artifact ID '{self.id}'.")
55
+ if int(match.group("turn")) < 1:
56
+ raise ArtifactError("Artifact ID turn must be between 1 and 9999.")
57
+ if self.direction not in ("input", "output"):
58
+ raise ArtifactError(f"Invalid artifact direction '{self.direction}'.")
59
+ if match.group("direction") != self.direction:
60
+ raise ArtifactError("Artifact ID direction does not match direction.")
61
+ if isinstance(self.index, bool) or not isinstance(self.index, int):
62
+ raise ArtifactError("Artifact index must be an integer.")
63
+ if self.index < 0 or self.index > 998:
64
+ raise ArtifactError("Artifact index must be between 0 and 998.")
65
+ if int(match.group("number")) != self.index + 1:
66
+ raise ArtifactError("Artifact ID number does not match index.")
67
+ if self.kind != "image":
68
+ raise ArtifactError("Artifact kind must be 'image'.")
69
+ if not isinstance(self.mime_type, str) or self.mime_type not in MIME_EXTENSIONS:
70
+ raise ArtifactError(f"Unsupported artifact MIME type '{self.mime_type}'.")
71
+ if (
72
+ isinstance(self.size_bytes, bool)
73
+ or not isinstance(self.size_bytes, int)
74
+ or self.size_bytes < 0
75
+ ):
76
+ raise ArtifactError("Artifact size_bytes must be a non-negative integer.")
77
+ if (
78
+ not isinstance(self.sha256, str)
79
+ or SHA256_PATTERN.fullmatch(self.sha256) is None
80
+ ):
81
+ raise ArtifactError(
82
+ "Artifact sha256 must be 64 lowercase hexadecimal digits."
83
+ )
84
+
85
+ relative = _validate_relative_path(self.relative_path)
86
+ expected = PurePosixPath(
87
+ f"turn-{match.group('turn')}",
88
+ f"{self.direction}-{self.index + 1:03d}{MIME_EXTENSIONS[self.mime_type]}",
89
+ )
90
+ if relative != expected:
91
+ raise ArtifactError(
92
+ f"Artifact relative_path must be '{expected.as_posix()}'."
93
+ )
94
+
95
+ @property
96
+ def turn(self) -> int:
97
+ """Return the one-based turn encoded in the artifact ID."""
98
+ match = ARTIFACT_ID_PATTERN.fullmatch(self.id)
99
+ assert match is not None
100
+ return int(match.group("turn"))
101
+
102
+ def to_record(self) -> dict[str, Any]:
103
+ """Serialize metadata for a schema-v2 JSONL turn record."""
104
+ return {
105
+ "id": self.id,
106
+ "kind": self.kind,
107
+ "direction": self.direction,
108
+ "index": self.index,
109
+ "mime_type": self.mime_type,
110
+ "size_bytes": self.size_bytes,
111
+ "sha256": self.sha256,
112
+ "relative_path": self.relative_path,
113
+ }
114
+
115
+ @classmethod
116
+ def from_record(cls, value: Any) -> Artifact:
117
+ """Parse and strictly validate one JSON artifact record."""
118
+ if not isinstance(value, dict):
119
+ raise ArtifactError("Artifact record must be an object.")
120
+ required = {
121
+ "id",
122
+ "kind",
123
+ "direction",
124
+ "index",
125
+ "mime_type",
126
+ "size_bytes",
127
+ "sha256",
128
+ "relative_path",
129
+ }
130
+ if set(value) != required:
131
+ missing = sorted(required - set(value))
132
+ extra = sorted(set(value) - required)
133
+ detail = []
134
+ if missing:
135
+ detail.append(f"missing {', '.join(missing)}")
136
+ if extra:
137
+ detail.append(f"unexpected {', '.join(extra)}")
138
+ raise ArtifactError(f"Invalid artifact record: {'; '.join(detail)}.")
139
+ return cls(
140
+ id=value["id"],
141
+ kind=value["kind"],
142
+ direction=value["direction"],
143
+ index=value["index"],
144
+ mime_type=value["mime_type"],
145
+ size_bytes=value["size_bytes"],
146
+ sha256=value["sha256"],
147
+ relative_path=value["relative_path"],
148
+ )
149
+
150
+ def to_public(
151
+ self,
152
+ session_id: str,
153
+ asset_root: Path,
154
+ *,
155
+ include_local_path: bool = True,
156
+ ) -> dict[str, Any]:
157
+ """Serialize metadata for callers, including a stable resource URI."""
158
+ _validate_session_id(session_id)
159
+ local_path = resolve_relative_path(asset_root, self.relative_path)
160
+ result = {
161
+ "id": self.id,
162
+ "kind": self.kind,
163
+ "direction": self.direction,
164
+ "index": self.index,
165
+ "mime_type": self.mime_type,
166
+ "size_bytes": self.size_bytes,
167
+ "sha256": self.sha256,
168
+ "uri": f"conferllm://sessions/{session_id}/artifacts/{self.id}",
169
+ }
170
+ if include_local_path:
171
+ result["local_path"] = str(local_path)
172
+ return result
173
+
174
+
175
+ class ArtifactTransaction:
176
+ """Stage and atomically install all artifacts belonging to one turn."""
177
+
178
+ def __init__(
179
+ self,
180
+ *,
181
+ session_id: str,
182
+ turn: int,
183
+ asset_root: Path,
184
+ staging_parent: Path,
185
+ ) -> None:
186
+ _validate_session_id(session_id)
187
+ _validate_turn(turn)
188
+ if asset_root.is_symlink() or staging_parent.is_symlink():
189
+ raise ArtifactError("Artifact directories must not be a symlink.")
190
+ if asset_root.exists() and not asset_root.is_dir():
191
+ raise ArtifactError("Artifact root must be a directory.")
192
+ self.session_id = session_id
193
+ self.turn = turn
194
+ self.asset_root = asset_root.resolve(strict=False)
195
+ self.staging_parent = staging_parent.resolve(strict=False)
196
+ _ensure_directory(self.staging_parent)
197
+ staging_name = tempfile.mkdtemp(
198
+ prefix=f".{session_id}.turn-{turn:04d}.",
199
+ suffix=".staging",
200
+ dir=self.staging_parent,
201
+ )
202
+ self.staging_path = Path(staging_name)
203
+ os.chmod(self.staging_path, 0o700)
204
+ self.final_path = self.asset_root / f"turn-{turn:04d}"
205
+ self._artifacts: list[Artifact] = []
206
+ self._installed = False
207
+ self._finalized = False
208
+
209
+ @property
210
+ def artifacts(self) -> tuple[Artifact, ...]:
211
+ """Return staged artifact metadata in staging order."""
212
+ return tuple(self._artifacts)
213
+
214
+ @property
215
+ def installed(self) -> bool:
216
+ """Return whether the turn directory has been installed."""
217
+ return self._installed
218
+
219
+ def stage_bytes(
220
+ self,
221
+ data: bytes,
222
+ *,
223
+ direction: ArtifactDirection,
224
+ mime_type: str,
225
+ index: int | None = None,
226
+ ) -> Artifact:
227
+ """Write one immutable artifact into the private staging directory."""
228
+ self._ensure_active()
229
+ if not isinstance(data, bytes):
230
+ raise ArtifactError("Artifact data must be bytes.")
231
+ if direction not in ("input", "output"):
232
+ raise ArtifactError(f"Invalid artifact direction '{direction}'.")
233
+ if mime_type not in MIME_EXTENSIONS:
234
+ raise ArtifactError(f"Unsupported artifact MIME type '{mime_type}'.")
235
+
236
+ if index is None:
237
+ index = sum(artifact.direction == direction for artifact in self._artifacts)
238
+ _validate_index(index)
239
+ artifact_id = f"t{self.turn:04d}-{direction}-{index + 1:03d}"
240
+ if any(artifact.id == artifact_id for artifact in self._artifacts):
241
+ raise ArtifactError(f"Artifact '{artifact_id}' is already staged.")
242
+
243
+ filename = f"{direction}-{index + 1:03d}{MIME_EXTENSIONS[mime_type]}"
244
+ relative_path = f"turn-{self.turn:04d}/{filename}"
245
+ artifact = Artifact(
246
+ id=artifact_id,
247
+ direction=direction,
248
+ index=index,
249
+ mime_type=mime_type,
250
+ size_bytes=len(data),
251
+ sha256=hashlib.sha256(data).hexdigest(),
252
+ relative_path=relative_path,
253
+ )
254
+ destination = self.staging_path / filename
255
+ descriptor: int | None = None
256
+ try:
257
+ descriptor = os.open(
258
+ destination,
259
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
260
+ 0o600,
261
+ )
262
+ _write_all(descriptor, data)
263
+ os.fsync(descriptor)
264
+ os.fchmod(descriptor, 0o600)
265
+ os.close(descriptor)
266
+ descriptor = None
267
+ _fsync_directory(self.staging_path)
268
+ except OSError as exc:
269
+ if descriptor is not None:
270
+ with suppress(OSError):
271
+ os.close(descriptor)
272
+ with suppress(FileNotFoundError):
273
+ destination.unlink()
274
+ self.rollback()
275
+ raise ArtifactError(
276
+ f"Unable to stage artifact '{artifact_id}': {exc}"
277
+ ) from exc
278
+
279
+ self._artifacts.append(artifact)
280
+ return artifact
281
+
282
+ def stage_file(
283
+ self,
284
+ source: Path,
285
+ *,
286
+ direction: ArtifactDirection,
287
+ mime_type: str,
288
+ index: int | None = None,
289
+ ) -> Artifact:
290
+ """Read a source once and stage its bytes."""
291
+ try:
292
+ expanded_source = source.expanduser()
293
+ if expanded_source.is_symlink():
294
+ raise OSError("source must not be a symlink")
295
+ source_path = expanded_source.resolve(strict=True)
296
+ if not source_path.is_file():
297
+ raise OSError("source is not a regular file")
298
+ data = source_path.read_bytes()
299
+ except OSError as exc:
300
+ self.rollback()
301
+ raise ArtifactError(
302
+ f"Unable to read artifact source '{source}': {exc}"
303
+ ) from exc
304
+ return self.stage_bytes(
305
+ data,
306
+ direction=direction,
307
+ mime_type=mime_type,
308
+ index=index,
309
+ )
310
+
311
+ def install(self) -> Path:
312
+ """Atomically move the complete staged turn into canonical storage."""
313
+ if self._finalized:
314
+ raise ArtifactError("Artifact transaction is already finalized.")
315
+ if self._installed:
316
+ return self.final_path
317
+ self._ensure_active()
318
+ try:
319
+ _ensure_directory(self.asset_root)
320
+ _validate_child(self.asset_root, self.final_path)
321
+ if self.final_path.exists() or self.final_path.is_symlink():
322
+ raise ArtifactError(
323
+ f"Artifact turn directory already exists: {self.final_path}"
324
+ )
325
+ os.replace(self.staging_path, self.final_path)
326
+ self._installed = True
327
+ _fsync_directory(self.asset_root)
328
+ except ArtifactError:
329
+ self.rollback()
330
+ raise
331
+ except OSError as exc:
332
+ self.rollback()
333
+ raise ArtifactError(
334
+ f"Unable to install artifacts for turn {self.turn}: {exc}"
335
+ ) from exc
336
+ return self.final_path
337
+
338
+ def finalize(self) -> tuple[Artifact, ...]:
339
+ """Mark an installed transaction as committed."""
340
+ if not self._installed:
341
+ raise ArtifactError("Artifact transaction must be installed first.")
342
+ self._finalized = True
343
+ return self.artifacts
344
+
345
+ def rollback(self) -> None:
346
+ """Remove staged or installed files unless the transaction was finalized."""
347
+ if self._finalized:
348
+ return
349
+ candidates = [(self.staging_path, self.staging_parent)]
350
+ if self._installed:
351
+ candidates.append((self.final_path, self.asset_root))
352
+ for candidate, parent in candidates:
353
+ _validate_child(parent, candidate)
354
+ if candidate.is_symlink():
355
+ candidate.unlink()
356
+ elif candidate.exists():
357
+ shutil.rmtree(candidate)
358
+ self._installed = False
359
+
360
+ def __enter__(self) -> ArtifactTransaction:
361
+ return self
362
+
363
+ def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
364
+ if exc_type is not None or not self._finalized:
365
+ self.rollback()
366
+
367
+ def _ensure_active(self) -> None:
368
+ if self._finalized:
369
+ raise ArtifactError("Artifact transaction is already finalized.")
370
+ if self._installed:
371
+ raise ArtifactError("Artifact transaction is already installed.")
372
+ if not self.staging_path.is_dir() or self.staging_path.is_symlink():
373
+ raise ArtifactError("Artifact staging directory is unavailable.")
374
+
375
+
376
+ # ArtifactBatch is the shorter public name used by callers that build a turn.
377
+ ArtifactBatch = ArtifactTransaction
378
+
379
+
380
+ def resolve_relative_path(asset_root: Path, relative_path: str) -> Path:
381
+ """Resolve a validated artifact path without permitting root escape."""
382
+ relative = _validate_relative_path(relative_path)
383
+ root = asset_root.resolve(strict=False)
384
+ candidate = root.joinpath(*relative.parts)
385
+ _validate_child(root, candidate)
386
+ return candidate
387
+
388
+
389
+ def verify_artifact_file(path: Path, artifact: Artifact) -> bytes:
390
+ """Read an artifact and verify its persisted size and SHA-256."""
391
+ try:
392
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
393
+ with os.fdopen(descriptor, "rb") as stream:
394
+ metadata = os.fstat(stream.fileno())
395
+ if not stat.S_ISREG(metadata.st_mode):
396
+ raise OSError("artifact is not a regular file")
397
+ if metadata.st_size != artifact.size_bytes:
398
+ raise ArtifactError(
399
+ f"Artifact '{artifact.id}' size does not match its record.",
400
+ code="session_corrupt",
401
+ )
402
+ data = stream.read(artifact.size_bytes + 1)
403
+ except OSError as exc:
404
+ raise ArtifactError(
405
+ f"Unable to read artifact '{artifact.id}': {exc}",
406
+ code="artifact_not_found"
407
+ if isinstance(exc, FileNotFoundError)
408
+ else "storage_error",
409
+ ) from exc
410
+ if len(data) != artifact.size_bytes:
411
+ raise ArtifactError(
412
+ f"Artifact '{artifact.id}' size does not match its record.",
413
+ code="session_corrupt",
414
+ )
415
+ if hashlib.sha256(data).hexdigest() != artifact.sha256:
416
+ raise ArtifactError(
417
+ f"Artifact '{artifact.id}' hash does not match its record.",
418
+ code="session_corrupt",
419
+ )
420
+ return data
421
+
422
+
423
+ def _validate_relative_path(value: str) -> PurePosixPath:
424
+ if not isinstance(value, str) or not value:
425
+ raise ArtifactError("Artifact relative_path must be a non-empty string.")
426
+ if "\\" in value:
427
+ raise ArtifactError("Artifact relative_path must use POSIX separators.")
428
+ path = PurePosixPath(value)
429
+ if path.is_absolute() or len(path.parts) != 2:
430
+ raise ArtifactError(
431
+ "Artifact relative_path must have exactly two relative parts."
432
+ )
433
+ if any(part in ("", ".", "..") for part in path.parts):
434
+ raise ArtifactError("Artifact relative_path contains an unsafe component.")
435
+ if path.as_posix() != value:
436
+ raise ArtifactError("Artifact relative_path is not canonical.")
437
+ return path
438
+
439
+
440
+ def _validate_session_id(session_id: str) -> None:
441
+ if (
442
+ not isinstance(session_id, str)
443
+ or SESSION_ID_PATTERN.fullmatch(session_id) is None
444
+ ):
445
+ raise ArtifactError(f"Invalid session ID '{session_id}'.")
446
+
447
+
448
+ def _validate_turn(turn: int) -> None:
449
+ if isinstance(turn, bool) or not isinstance(turn, int) or not 1 <= turn <= 9999:
450
+ raise ArtifactError("Artifact turn must be between 1 and 9999.")
451
+
452
+
453
+ def _validate_index(index: int) -> None:
454
+ if isinstance(index, bool) or not isinstance(index, int) or not 0 <= index <= 998:
455
+ raise ArtifactError("Artifact index must be between 0 and 998.")
456
+
457
+
458
+ def _ensure_directory(path: Path) -> None:
459
+ if path.is_symlink():
460
+ raise ArtifactError(f"Artifact directory must not be a symlink: {path}")
461
+ try:
462
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
463
+ if not path.is_dir():
464
+ raise OSError("path is not a directory")
465
+ os.chmod(path, 0o700)
466
+ except OSError as exc:
467
+ raise ArtifactError(
468
+ f"Unable to prepare artifact directory '{path}': {exc}"
469
+ ) from exc
470
+
471
+
472
+ def _validate_child(parent: Path, child: Path) -> None:
473
+ parent_resolved = parent.resolve(strict=False)
474
+ child_resolved = child.resolve(strict=False)
475
+ try:
476
+ child_resolved.relative_to(parent_resolved)
477
+ except ValueError as exc:
478
+ raise ArtifactError(
479
+ f"Artifact path resolves outside '{parent_resolved}'."
480
+ ) from exc
481
+
482
+
483
+ def _write_all(descriptor: int, data: bytes) -> None:
484
+ remaining = memoryview(data)
485
+ while remaining:
486
+ written = os.write(descriptor, remaining)
487
+ if written == 0:
488
+ raise OSError("zero-byte write while writing artifact data")
489
+ remaining = remaining[written:]
490
+
491
+
492
+ def _fsync_directory(path: Path) -> None:
493
+ try:
494
+ descriptor = os.open(path, os.O_RDONLY)
495
+ except OSError:
496
+ return
497
+ try:
498
+ os.fsync(descriptor)
499
+ except OSError:
500
+ pass
501
+ finally:
502
+ os.close(descriptor)