weft-memory 0.1.0a0__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.
weft/ingest.py ADDED
@@ -0,0 +1,301 @@
1
+ """Phase 3 ingestion (spec §1, D3-3..D3-9).
2
+
3
+ Every entry point returns either a record id or a Rejection with a named reason.
4
+ Nothing is recorded as a success when extraction fails (D3-9).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import io
12
+ import os
13
+ import re
14
+ import uuid
15
+ import zipfile
16
+ from dataclasses import dataclass
17
+
18
+ from weft.substrate import Record, RecordConflict, ScopeViolation
19
+
20
+ def _centroid(vectors: list) -> list:
21
+ """Normalised mean of the given vectors (note-level representation, no extra model calls)."""
22
+ dim = len(vectors[0])
23
+ mean = [sum(v[i] for v in vectors) / len(vectors) for i in range(dim)]
24
+ norm = sum(x * x for x in mean) ** 0.5 or 1.0
25
+ return [x / norm for x in mean]
26
+
27
+
28
+ DOC_HEAD_CHARS = 2000 # opening text embedded per document when doc_vectors is on
29
+ _TURN_ATTRIBUTION = {"user": "user", "assistant": "agent", "system": "system"}
30
+ _FENCE = re.compile(r"<weft-recall\b[^>]*>.*?</weft-recall>", re.DOTALL | re.IGNORECASE)
31
+ _STRAY = re.compile(r"</?weft-recall\b[^>]*>", re.IGNORECASE)
32
+ _OPEN = re.compile(r"<weft-recall\b[^>]*>", re.IGNORECASE)
33
+ _CLOSE = re.compile(r"</weft-recall>", re.IGNORECASE)
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Rejection:
38
+ name: str
39
+ reason: str
40
+
41
+
42
+ def fence(text: str) -> tuple[str, bool]:
43
+ """Strip recall blocks and stray recall tags (D3-5). Returns (text, changed).
44
+
45
+ Blocks are removed until none remain (review G-P3-8: nested or repeated blocks
46
+ must not leave recalled content behind), then any stray tag token is removed.
47
+ """
48
+ cleaned = text
49
+ while True:
50
+ opened = _OPEN.search(cleaned)
51
+ if opened is None:
52
+ break
53
+ closes = list(_CLOSE.finditer(cleaned, opened.end()))
54
+ if not closes:
55
+ break
56
+ # Conservative: everything from the first open tag through the last close tag goes.
57
+ cleaned = cleaned[:opened.start()] + cleaned[closes[-1].end():]
58
+ cleaned = _STRAY.sub("", cleaned)
59
+ return cleaned, cleaned != text
60
+
61
+
62
+ def _extract_paragraphs(text: str) -> list[str]:
63
+ return [p.strip() for p in re.split(r"\n\s*\n", text) if p.strip()]
64
+
65
+
66
+ def _digest(data: bytes) -> str:
67
+ return hashlib.sha256(data).hexdigest()
68
+
69
+
70
+ def _record_digest(record: Record) -> str:
71
+ row = list(record.to_row())
72
+ del row[5] # creation time is not part of the intended content
73
+ return _digest(json.dumps(row, separators=(',', ':')).encode())
74
+
75
+
76
+ def _parse(name: str, data: bytes):
77
+ """Return extracted text, or a Rejection. Never returns partial text on failure."""
78
+ ext = os.path.splitext(name.lower())[1]
79
+ if ext in (".txt", ".md"):
80
+ try:
81
+ return data.decode("utf-8")
82
+ except UnicodeDecodeError:
83
+ return Rejection(name, "encoding_unsupported")
84
+ if ext == ".pdf":
85
+ if not data.startswith(b"%PDF"):
86
+ return Rejection(name, "parse_failed")
87
+ if b"/Encrypt" in data:
88
+ return Rejection(name, "encrypted_pdf")
89
+ try:
90
+ from pypdf import PdfReader
91
+ reader = PdfReader(io.BytesIO(data))
92
+ if reader.is_encrypted:
93
+ return Rejection(name, "encrypted_pdf")
94
+ text = "\n\n".join((page.extract_text() or "") for page in reader.pages).strip()
95
+ except Exception:
96
+ return Rejection(name, "parse_failed")
97
+ return text if text else Rejection(name, "no_text_layer")
98
+ if ext == ".docx":
99
+ try:
100
+ import docx
101
+ doc = docx.Document(io.BytesIO(data))
102
+ text = "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
103
+ except Exception:
104
+ return Rejection(name, "parse_failed")
105
+ return text if text else Rejection(name, "no_text_layer")
106
+ return Rejection(name, "unsupported_format")
107
+
108
+
109
+ class Ingestor:
110
+ """Writes Sources and Observations through the substrate. Holds no state of its own."""
111
+
112
+ def __init__(self, substrate, embedder=None, doc_vectors=False, doc_centroid=True):
113
+ self.sub = substrate
114
+ self.embedder = embedder # optional: anything with embed_document(text) -> vector
115
+ self.doc_vectors = doc_vectors # also embed each document's opening text (note-level)
116
+ self.doc_centroid = doc_centroid # note-level vector: normalised mean of paragraph vectors (default on)
117
+
118
+ def _put_matching(self, record, ctx):
119
+ """Recover only an identical, currently readable and writable incumbent."""
120
+ try:
121
+ self.sub.put(record, ctx)
122
+ except RecordConflict:
123
+ incumbent = self.sub._lookup_record(record.record_id)
124
+ engine = self.sub.scope_engine
125
+ if (incumbent is None
126
+ or not engine.authorize_write(incumbent, ctx).allowed
127
+ or not engine.authorize_read(incumbent.scope, ctx, engine._payload_text(incumbent)).allowed
128
+ or any(getattr(incumbent, field) != getattr(record, field) for field in (
129
+ 'record_id', 'record_type', 'scope', 'payload', 'epistemic_status',
130
+ 'effective_at', 'supersedes', 'source_ids', 'provenance', 'attribution'))):
131
+ raise
132
+
133
+ def ingest_turn(self, text: str, speaker: str, ctx, scope: str, conversation_id: str) -> str:
134
+ """A conversation turn becomes a Source with the speaker's attribution (spec §7.1)."""
135
+ attribution = _TURN_ATTRIBUTION.get(speaker)
136
+ if attribution is None:
137
+ raise ValueError(f"unknown speaker: {speaker!r}")
138
+ clean, changed = fence(text)
139
+ provenance = {"conversation_id": conversation_id, "speaker": speaker}
140
+ if changed:
141
+ provenance["fenced"] = True
142
+ rid = "turn-" + uuid.uuid4().hex[:24]
143
+ record = Record(
144
+ record_id=rid, record_type="Source", scope=scope, payload={"text": clean},
145
+ attribution=attribution, source_ids=[], provenance=provenance,
146
+ )
147
+ if not self.sub.scope_engine.authorize_write(record, ctx).allowed:
148
+ raise ScopeViolation("write not permitted", audit_recorded=False)
149
+ vector = self.embedder.embed_document(clean) if self.embedder is not None else None
150
+ with self.sub.ingestion_transaction():
151
+ self.sub.put(record, ctx)
152
+ if self.embedder is not None:
153
+ self.sub.vector_put(rid, scope, vector, ctx=ctx, model_id=self.embedder.model_id)
154
+ return rid
155
+
156
+ def ingest_document(self, name: str, data: bytes, ctx, scope: str, author: str):
157
+ """Document text is evidence: Observations carry attribution `document` (D3-3)."""
158
+ parsed = _parse(name, data)
159
+ if isinstance(parsed, Rejection):
160
+ return parsed
161
+ parsed, _ = fence(parsed) # review G-P3-4: recall blocks never persist from documents
162
+ if not parsed.strip():
163
+ return Rejection(name, "empty_document")
164
+ content_hash = _digest(data)
165
+ sid = "doc-" + hashlib.sha256(f"{scope}\0{content_hash}".encode()).hexdigest()[:24]
166
+ paragraphs = _extract_paragraphs(parsed)
167
+ source = Record(
168
+ record_id=sid, record_type="Source", scope=scope,
169
+ payload={"text": parsed, "name": name}, attribution="document", source_ids=[],
170
+ provenance={"author": author, "document": name, "content_hash": content_hash},
171
+ )
172
+ observations = [Record(
173
+ record_id=f"{sid}-p{i:04d}", record_type="Observation", scope=scope,
174
+ payload={"text": paragraph}, attribution="document", source_ids=[sid],
175
+ provenance={"location": f"paragraph {i + 1}", "document": name},
176
+ ) for i, paragraph in enumerate(paragraphs)]
177
+ if any(not self.sub.scope_engine.authorize_write(record, ctx).allowed
178
+ for record in [source, *observations]):
179
+ raise ScopeViolation("write not permitted", audit_recorded=False)
180
+ state = self.sub.job_state(sid, "ingest", content_hash, scope=scope, ctx=ctx)
181
+ if state == "done":
182
+ with self.sub.ingestion_transaction():
183
+ # A completed bundle with missing members may reflect a deletion.
184
+ # Never repair it implicitly by recreating those records on retry.
185
+ if any(self.sub._lookup_record(record.record_id) is None
186
+ for record in [source, *observations]):
187
+ raise RecordConflict('completed ingestion is incomplete')
188
+ self._put_matching(source, ctx)
189
+ for observation in observations:
190
+ self._put_matching(observation, ctx)
191
+ if self.embedder is None:
192
+ return sid
193
+ # Existing vector rows carry no generation-configuration evidence.
194
+ # Recompute rather than treating their presence as a matching mode.
195
+
196
+ # Compute every embedding before writing the job, Source, or Observations. Model
197
+ # load/inference failures therefore cannot leave an apparently successful partial import.
198
+ paragraph_vectors = []
199
+ source_vector = None
200
+ if self.embedder is not None:
201
+ paragraph_vectors = [self.embedder.embed_document(paragraph) for paragraph in paragraphs]
202
+ if self.doc_vectors:
203
+ source_vector = self.embedder.embed_document(parsed[:DOC_HEAD_CHARS])
204
+ if self.doc_centroid and paragraph_vectors:
205
+ source_vector = _centroid(paragraph_vectors)
206
+
207
+ with self.sub.ingestion_transaction():
208
+ # Read completion under the write transaction: another ingestion may
209
+ # have completed and a member been deleted while embeddings ran.
210
+ current_state = self.sub.job_state(sid, "ingest", content_hash, scope=scope, ctx=ctx)
211
+ if (state == "done" or current_state == "done") and any(
212
+ self.sub._lookup_record(record.record_id) is None
213
+ for record in [source, *observations]):
214
+ raise RecordConflict('completed ingestion is incomplete')
215
+ self.sub.set_job(sid, "ingest", content_hash, "running", scope=scope, ctx=ctx)
216
+ self._put_matching(source, ctx)
217
+ # A preexisting source vector would contradict this requested mode.
218
+ # Refuse rather than silently retaining it under the same model ID.
219
+ if (self.embedder is not None and not self.doc_vectors and not self.doc_centroid
220
+ and self.sub.vector_has_record(sid, scope, ctx, model_id=self.embedder.model_id)):
221
+ raise ValueError('existing source vector conflicts with requested vector mode')
222
+ for i, observation in enumerate(observations):
223
+ oid = observation.record_id
224
+ self._put_matching(observation, ctx)
225
+ if self.embedder is not None:
226
+ self.sub.vector_put(oid, scope, paragraph_vectors[i], ctx=ctx, model_id=self.embedder.model_id)
227
+ if self.embedder is not None and source_vector is not None:
228
+ self.sub.vector_put(sid, scope, source_vector, ctx=ctx, model_id=self.embedder.model_id)
229
+ self.sub.set_job(sid, "ingest", content_hash, "done", scope=scope, ctx=ctx)
230
+ return sid
231
+
232
+ def ingest_media(self, name: str, data: bytes, kind: str, ctx, scope: str,
233
+ describer=None, transcriber=None):
234
+ """Image or audio becomes machine-derived text. Never presented as verbatim (D3-6)."""
235
+ if kind == "image":
236
+ adapter = describer
237
+ elif kind == "audio":
238
+ adapter = transcriber
239
+ else:
240
+ return Rejection(name, "unsupported_kind")
241
+ if adapter is None:
242
+ return Rejection(name, "no_adapter_configured")
243
+ content_hash = _digest(data)
244
+ sid = "media-" + hashlib.sha256(f"{scope}\0{content_hash}".encode()).hexdigest()[:24]
245
+ stub = Record(record_id=sid, record_type="Source", scope=scope, payload={}, attribution="document")
246
+ if ctx is None or not self.sub.scope_engine.authorize_write(stub, ctx).allowed:
247
+ raise ScopeViolation("write not permitted")
248
+ with self.sub.ingestion_transaction():
249
+ row = self.sub._conn.execute(
250
+ "SELECT state FROM jobs WHERE scope=? AND source_id=? AND stage='derive' AND content_hash=?",
251
+ (scope, sid, content_hash),
252
+ ).fetchone()
253
+ # The public job writer and an unkeyed digest cannot attest inference.
254
+ # Until trusted provenance exists, no completed media job is reusable.
255
+ if row and row[0] == 'done':
256
+ raise RecordConflict('completed media reuse is unavailable')
257
+ self.sub.set_job(sid, "derive", content_hash, "running", scope=scope, ctx=ctx)
258
+ try:
259
+ text = adapter(data)
260
+ except Exception:
261
+ self.sub.set_job(sid, "derive", content_hash, "failed", "adapter_failed", scope=scope, ctx=ctx)
262
+ return Rejection(name, "adapter_failed")
263
+ if isinstance(text, str):
264
+ text, _ = fence(text) # review G-P3-4: derived text is fenced like any other input
265
+ if not isinstance(text, str) or not text.strip():
266
+ self.sub.set_job(sid, "derive", content_hash, "failed", "empty_output", scope=scope, ctx=ctx)
267
+ return Rejection(name, "empty_output")
268
+ vector = None
269
+ if self.embedder is not None:
270
+ try:
271
+ vector = self.embedder.embed_document(text.strip())
272
+ except Exception as exc:
273
+ try:
274
+ self.sub.set_job(sid, "derive", content_hash, "failed", "embedding_failed", scope=scope, ctx=ctx)
275
+ except Exception as job_exc:
276
+ exc.add_note(f"Could not record failed job: {job_exc}")
277
+ raise
278
+ failure_reason = "persistence_failed"
279
+ try:
280
+ with self.sub.ingestion_transaction():
281
+ model = getattr(adapter, "model_id", "unknown")
282
+ output = Record(
283
+ record_id=sid, record_type="Source", scope=scope, payload={"text": text.strip()},
284
+ attribution="media-derived", source_ids=[],
285
+ provenance={"machine_derived": True, "model": model, "kind": kind,
286
+ "name": name, "content_hash": content_hash},
287
+ )
288
+ # Reusing a preexisting output is not proof this adapter produced it.
289
+ self.sub.put(output, ctx)
290
+ if self.embedder is not None:
291
+ failure_reason = "embedding_failed"
292
+ self.sub.vector_put(sid, scope, vector, ctx=ctx, model_id=self.embedder.model_id)
293
+ failure_reason = "persistence_failed"
294
+ self.sub.set_job(sid, "derive", content_hash, "done", 'sha256:' + _record_digest(output), scope=scope, ctx=ctx)
295
+ except Exception as exc:
296
+ try:
297
+ self.sub.set_job(sid, "derive", content_hash, "failed", failure_reason, scope=scope, ctx=ctx)
298
+ except Exception as job_exc:
299
+ exc.add_note(f"Could not record failed job: {job_exc}")
300
+ raise
301
+ return sid
weft/knowledge.py ADDED
@@ -0,0 +1,109 @@
1
+ """Phase 2 knowledge rules (spec §3, D2-2, D2-3, D2-9).
2
+
3
+ A pure decision function over a record and the caller's context. Storage lookups
4
+ are passed in, so the rules stay testable and cannot reach around the store.
5
+ Returns a reason string when the write must be refused, or None when allowed.
6
+ Callers never show the reason to the writer (generic denial, G4/G10).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable
13
+
14
+
15
+ class KnowledgeRefusal(Exception):
16
+ """Raised inside a write transaction; the store turns it into a generic denial."""
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Rule:
21
+ # attribution -> statuses that attribution may produce for this type
22
+ statuses: dict = field(default_factory=dict)
23
+ needs_source: bool = False
24
+
25
+
26
+ def _s(*names: str) -> frozenset:
27
+ return frozenset(names)
28
+
29
+
30
+ RULES: dict[str, Rule] = {
31
+ "Person": Rule({"user": _s("stated"), "document": _s("stated")}),
32
+ "Agent": Rule({"system": _s("stated")}),
33
+ "Project": Rule({"user": _s("stated")}),
34
+ # Phase 3 (spec §1): assistant turns are agent-attributed Sources; fenced
35
+ # or system-injected text is system-attributed. Neither can yield Decisions.
36
+ "Source": Rule({
37
+ "document": _s("stated"), "media-derived": _s("stated"), "user": _s("stated"),
38
+ "agent": _s("stated"), "system": _s("stated"),
39
+ }),
40
+ "Conversation": Rule({"system": _s("stated")}),
41
+ "Observation": Rule(
42
+ {"document": _s("stated"), "media-derived": _s("stated")}, needs_source=True
43
+ ),
44
+ "Claim": Rule({"agent": _s("derived"), "verifier": _s("verified")}),
45
+ "Hypothesis": Rule({"agent": _s("derived")}),
46
+ "Decision": Rule({"user": _s("stated")}),
47
+ "Commitment": Rule({"user": _s("stated")}),
48
+ "Correction": Rule({"user": _s("stated")}),
49
+ "Verification": Rule({"verifier": _s("stated")}),
50
+ "Deletion": Rule({"user": _s("stated")}),
51
+ "Summary": Rule({"system": _s("derived")}),
52
+ "RecallReceipt": Rule({"system": _s("stated")}),
53
+ }
54
+
55
+
56
+ def check_record(
57
+ record,
58
+ ctx,
59
+ *,
60
+ is_verifier: Callable[[str | None], bool],
61
+ lookup: Callable[[str], object | None],
62
+ ) -> str | None:
63
+ """Return a refusal reason, or None if the record may be written."""
64
+ rule = RULES.get(record.record_type)
65
+ if rule is None:
66
+ return "unknown record type"
67
+ attribution = record.attribution
68
+ if not attribution:
69
+ return "attribution required"
70
+ allowed = rule.statuses.get(attribution)
71
+ if allowed is None:
72
+ return "attribution not permitted for this record type"
73
+ if record.epistemic_status not in allowed:
74
+ return "status not permitted for this attribution"
75
+ if attribution == "user" and not ctx.user_authored:
76
+ # D2-2 / D2-7: "user" means the user's own turn. An agent cannot claim it.
77
+ return "user attribution requires a user-authored turn"
78
+ if rule.needs_source and not record.source_ids:
79
+ return "observation must cite a source"
80
+ if rule.needs_source:
81
+ for sid in record.source_ids:
82
+ src = lookup(sid)
83
+ # Same-scope only: a foreign or missing source is one generic refusal.
84
+ if src is None or src.scope != record.scope:
85
+ return "cited source not available"
86
+ if attribution == "verifier" and not is_verifier(ctx.actor):
87
+ return "actor is not a registered verifier"
88
+ if record.epistemic_status == "verified" and not _names_claim(record, lookup):
89
+ return "no verification names this claim"
90
+ return None
91
+
92
+
93
+ def _names_claim(claim, lookup) -> bool:
94
+ """D2-3: a verified Claim must cite a Verification that names it.
95
+
96
+ The Verification must be in the same scope as the Claim, so the answer
97
+ reveals nothing about records the writer cannot already see.
98
+ """
99
+ for vid in claim.source_ids:
100
+ v = lookup(vid)
101
+ if (
102
+ v is not None
103
+ and v.record_type == "Verification"
104
+ and v.attribution == "verifier"
105
+ and v.scope == claim.scope
106
+ and v.payload.get("claim_id") == claim.record_id
107
+ ):
108
+ return True
109
+ return False
weft/logging_config.py ADDED
@@ -0,0 +1,54 @@
1
+ """Phase 6: structured logging with redaction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import re
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from typing import Any
11
+
12
+
13
+ class RedactingFormatter(logging.Formatter):
14
+ """JSON formatter that redacts untrusted content fields."""
15
+
16
+ _PATTERNS = [
17
+ re.compile(r"<\s*UNTRUSTED_DOCUMENT\s*>", re.IGNORECASE),
18
+ re.compile(r"<\s*/\s*UNTRUSTED_DOCUMENT\s*>", re.IGNORECASE),
19
+ re.compile(r"\[\s*REDACTED\s+INJECTION\s*\]", re.IGNORECASE),
20
+ ]
21
+
22
+ def format(self, record: logging.LogRecord) -> str:
23
+ message = super().format(record)
24
+ for pat in self._PATTERNS:
25
+ message = pat.sub("[REDACTED]", message)
26
+ return message
27
+
28
+
29
+ def setup_logging(level: str = "INFO") -> None:
30
+ """Configure structured JSON logging with redaction."""
31
+ handler = logging.StreamHandler(sys.stdout)
32
+ handler.setFormatter(RedactingFormatter("%(message)s"))
33
+ root = logging.getLogger()
34
+ root.handlers = [handler]
35
+ root.setLevel(getattr(logging, level, logging.INFO))
36
+
37
+
38
+ def log_event(
39
+ operation: str,
40
+ record_id: str | None = None,
41
+ scope: str | None = None,
42
+ detail: str = "",
43
+ level: str = "INFO",
44
+ ) -> None:
45
+ """Emit a structured log event."""
46
+ entry = {
47
+ "timestamp": datetime.now(timezone.utc).isoformat(),
48
+ "level": level,
49
+ "operation": operation,
50
+ "record_id": record_id,
51
+ "scope": scope,
52
+ "detail": detail,
53
+ }
54
+ logging.getLogger("weft").log(getattr(logging, level, logging.INFO), json.dumps(entry))
weft/models.py ADDED
@@ -0,0 +1,99 @@
1
+ """Model layer for Weft: local and external models behind one interface.
2
+
3
+ Each task may use a different model. External calls require explicit opt-in
4
+ and are recorded in the receipt/audit trail.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, List, Optional
11
+
12
+
13
+ @dataclass
14
+ class ModelCall:
15
+ model_id: str
16
+ provider: str
17
+ task: str
18
+ input_chars: int = 0
19
+ output_chars: int = 0
20
+ egress: bool = False
21
+ error: Optional[str] = None
22
+
23
+
24
+ @dataclass
25
+ class ModelResult:
26
+ text: str
27
+ model: ModelCall
28
+ raw: Any = None
29
+
30
+
31
+ class ModelLayerError(Exception):
32
+ """Base error for model layer failures."""
33
+
34
+
35
+ class EgressPolicyError(ModelLayerError):
36
+ """External inference was attempted without opt-in."""
37
+
38
+
39
+ class ModelUnavailable(ModelLayerError):
40
+ """The requested model cannot be loaded or reached."""
41
+
42
+
43
+ class ModelLayer:
44
+ """One interface for local and external inference."""
45
+
46
+ def __init__(self, *, local_factory: Optional[Callable[[str], Any]] = None,
47
+ external_factory: Optional[Callable[[str, str], Any]] = None,
48
+ egress_allowed: bool = False) -> None:
49
+ self.local_factory = local_factory
50
+ self.external_factory = external_factory
51
+ self.egress_allowed = egress_allowed
52
+ self.calls: List[ModelCall] = []
53
+
54
+ def _record(self, call: ModelCall, text: str) -> ModelResult:
55
+ self.calls.append(call)
56
+ return ModelResult(text=text, model=call)
57
+
58
+ def _run_local(self, model_id: str, prompt: str, task: str) -> ModelResult:
59
+ if self.local_factory is None:
60
+ raise ModelUnavailable(f"no local model configured for {task}")
61
+ model = self.local_factory(model_id)
62
+ call = ModelCall(model_id=model_id, provider="local", task=task,
63
+ input_chars=len(prompt))
64
+ try:
65
+ text = model(prompt)
66
+ except Exception as exc:
67
+ call.error = str(exc)
68
+ self.calls.append(call)
69
+ raise ModelLayerError(f"local model failed: {exc}") from exc
70
+ call.output_chars = len(text)
71
+ return self._record(call, text)
72
+
73
+ def _run_external(self, model_id: str, prompt: str, task: str, base_url: str, api_key: str) -> ModelResult:
74
+ if not self.egress_allowed:
75
+ raise EgressPolicyError(f"external inference requires opt-in for {task}")
76
+ if self.external_factory is None:
77
+ raise ModelUnavailable(f"no external factory configured for {task}")
78
+ model = self.external_factory(model_id, base_url)
79
+ call = ModelCall(model_id=model_id, provider="external", task=task,
80
+ input_chars=len(prompt), egress=True)
81
+ try:
82
+ text = model(prompt, api_key=api_key)
83
+ except Exception as exc:
84
+ call.error = str(exc)
85
+ self.calls.append(call)
86
+ raise ModelLayerError(f"external model failed: {exc}") from exc
87
+ call.output_chars = len(text)
88
+ return self._record(call, text)
89
+
90
+ def run(self, model_id: str, prompt: str, task: str, *,
91
+ base_url: str = "", api_key: str = "", force_local: bool = False) -> ModelResult:
92
+ """Run a prompt through the appropriate model.
93
+
94
+ External calls require `egress_allowed=True` unless `force_local=True`.
95
+ Returns the result and records the call for audit.
96
+ """
97
+ if force_local or not base_url or not api_key:
98
+ return self._run_local(model_id, prompt, task)
99
+ return self._run_external(model_id, prompt, task, base_url, api_key)