weft-llm 1.0.0__tar.gz

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.
@@ -0,0 +1,78 @@
1
+ # The donor checkout, reachable for reading and lifting only.
2
+ # Never a build input: see CLAUDE.md and docs/README.md.
3
+ /donor
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ .venv/
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+
13
+ # Tooling
14
+ .pytest_cache/
15
+ .ruff_cache/
16
+ .pyright/
17
+
18
+ # `uv.lock` was here, filed between two caches. It is not a cache: `ci.yml` runs
19
+ # `uv sync --frozen` in three of its four jobs, and every one of them died at that step
20
+ # the first time CI ever ran. It is tracked now — the resolution CI installs and the
21
+ # resolution a developer installs must be one artefact, which is the same argument
22
+ # `docs/README.md` makes about single-sourcing anything two readers can disagree about.
23
+ #
24
+ # It constrains nobody downstream. A lockfile is not a dependency bound; what a consumer
25
+ # resolves is decided by each distribution's `pyproject.toml`.
26
+
27
+ # Session-local attempt counter for the guard_quality_gates.py PreToolUse hook — per-session
28
+ # scratch, not a record anyone should read later. See the hook's module docstring.
29
+ .claude/.gate-attempts.json
30
+
31
+ # Secrets. `.env` holds live provider keys; only the documented, valueless
32
+ # example is tracked. Listed before any tooling rule so a stray `git add -A`
33
+ # cannot reach it.
34
+ .env
35
+ .env.*
36
+ !.env.example
37
+
38
+ # Corpus payload. Every subdirectory of /corpus is a materialised document set and
39
+ # is deliberately untracked; `corpus/manifest.toml` and `scripts/fetch_corpus.py`
40
+ # are the tracked artefact. `09` §4 V1 permits exactly this:
41
+ # a corpus is "either redistributable or fetched by a pinned, checksummed script",
42
+ # and the mRMR papers are published under publisher copyright, so committing them
43
+ # would be redistribution this repository has no right to perform. The manifest
44
+ # carries a sha256 per document, which is what makes the set reproducible without
45
+ # the bytes being here. The pattern is a directory glob rather than a list of names
46
+ # so that scaling the corpus up cannot silently start tracking a paper.
47
+ /corpus/*/
48
+
49
+ # Where a baseline run stages the corpus it indexes and writes the `weft.toml` it measures
50
+ # through (`eval/run_baseline.py`). The staged copies are the same untracked papers one
51
+ # directory over, and the configuration is reproduced by the harness rather than kept — what
52
+ # is tracked is the run it produced, under `eval/baselines/`.
53
+ /.baseline-run/
54
+
55
+ # Working artefacts of a build session — a generated map of the codebase and a design
56
+ # record produced while planning. Untracked on purpose: `docs/README.md` routes every
57
+ # document this project owns, and a design that matters belongs in the `docs/` file
58
+ # that owns its content, not in a root-level file nothing points at.
59
+ /.phase2-*.md
60
+ /.phase3-*.md
61
+ /.phase4-*.md
62
+
63
+ # Gate-session preparation: the Bring lists of `docs/05-grilling-sessions.md`, measured on the day
64
+ # a session is about to run. Untracked for the same reason — the session's outcome belongs in
65
+ # `docs/README.md`'s decision log and in the reference document the decision changes, never here.
66
+ /.gate-brief-*.md
67
+
68
+ # Claude Code local state
69
+ .claude/settings.local.json
70
+
71
+ .DS_Store
72
+
73
+ # The build harness driving Phase 2, alongside its brief, findings and design record.
74
+ /.phase2-build.js
75
+
76
+ # Transient: harvested subagent findings, promoted into docs/lessons.md by implement-ll.
77
+ # Never committed — its content belongs in the queue or nowhere.
78
+ .claude/lessons-spool.md
weft_llm-1.0.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Krysztopa
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
weft_llm-1.0.0/NOTICE ADDED
@@ -0,0 +1,24 @@
1
+ Weft
2
+ Copyright (c) 2026 Adam Krysztopa
3
+
4
+ This product is licensed under the MIT License. See the LICENSE file at the root
5
+ of this repository.
6
+
7
+ --------------------------------------------------------------------------------
8
+ Original work
9
+ --------------------------------------------------------------------------------
10
+
11
+ **Weft contains no source text from any other codebase.** Every line here is
12
+ written for this project, against this project's contracts.
13
+
14
+ This is a rule, not a description of the current state: no file may be copied or
15
+ adapted from another project's source, and no third-party source text may be
16
+ pasted into this repository. Where a prior system informed a design, what was
17
+ carried across is understanding — an approach, an ordering, a measurement, a
18
+ reason a guard exists — restated in this project's own words and implemented
19
+ fresh. Copyright does not reach any of that, and nothing in this repository
20
+ depends on a licence granted by anyone else.
21
+
22
+ `docs/04-donor-inventory.md` records what was learned from prior work and what
23
+ was deliberately not taken. It is a design record. Nothing in it authorises a
24
+ copy, because copying is not permitted here at all.
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.5
2
+ Name: weft-llm
3
+ Version: 1.0.0
4
+ Summary: First-party generation-support pack. Publishes the LLMProvider contract, the LLMError taxonomy, and the deterministic offline scripted provider.
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ License-File: NOTICE
8
+ Requires-Python: >=3.12
9
+ Requires-Dist: weft-kernel<1.0.0,>=0.1.0
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "weft-llm"
3
+ version = "1.0.0"
4
+ description = "First-party generation-support pack. Publishes the LLMProvider contract, the LLMError taxonomy, and the deterministic offline scripted provider."
5
+ requires-python = ">=3.12"
6
+ license = "MIT"
7
+ license-files = ["LICENSE", "NOTICE"]
8
+ dependencies = ["weft-kernel>=0.1.0,<1.0.0"]
9
+
10
+ # The one entry point a pack declares (`docs/02-extension-model.md` section 2).
11
+ [project.entry-points."weft.packs"]
12
+ llm = "weft_llm:register"
13
+
14
+ [build-system]
15
+ requires = ["hatchling"]
16
+ build-backend = "hatchling.build"
@@ -0,0 +1,91 @@
1
+ """First-party generation-support pack. Publishes `LLMProvider` and registers no vendor.
2
+
3
+ Task **2.30**: "the generation pack names no vendor, because a provider adapter is its own
4
+ pack and the offline default is a deterministic scripted provider." This pack is the "own
5
+ pack" a vendor's adapter registers *under* — `weft-openai` (a separate distribution)
6
+ registers `openai` here the same way any stranger's `weft-anthropic` could, through the same
7
+ public `weft.packs` entry point, with nothing extra for being first-party.
8
+
9
+ Registers exactly one plugin itself: `scripted`, the deterministic offline default that
10
+ keeps `poe ci-checks` and every fresh checkout running with no credential and no network —
11
+ see `weft_llm.scripted`'s module docstring.
12
+
13
+ **Task 2.10 completed the pack**: the `LLM` service (`weft_llm.client`), the retry wrapper
14
+ (`weft_llm.retry`), the model-string vocabulary (`weft_llm.models`) and the role table the
15
+ service resolves against (`weft_llm.roles`). `LLM` and `TokenSink` are exported here for a
16
+ library caller, but neither carries a `version` and neither is registered under a name — the
17
+ mechanical rule `.phase2-design.md` §3 states for a *service*, which is what keeps both out of
18
+ `manual/contract-reference.md` and out of fitness function 9(c)'s left side. `NativeStructured`
19
+ is exported for the opposite reason: it *is* a capability, derived by `isinstance` from what a
20
+ provider implements, and the reference should say so.
21
+ """
22
+
23
+ from pydantic import BaseModel, ConfigDict
24
+
25
+ from weft_kernel.discovery import PackRegistrar
26
+ from weft_llm.client import LLMClient, NullSink, llm_service
27
+ from weft_llm.contract import LLM, LLM_CONTRACT_VERSION, LLMProvider, NativeStructured, TokenSink
28
+ from weft_llm.loop_guard import LoopGuardConfig, detect_generation_loop
29
+ from weft_llm.models import ModelRef, find_runtime_match, model_ref
30
+ from weft_llm.payload import (
31
+ Completion,
32
+ Conversation,
33
+ Message,
34
+ MessageRole,
35
+ OnFailure,
36
+ Rendered,
37
+ TokenChunk,
38
+ TokenUsage,
39
+ )
40
+ from weft_llm.retry import RetryPolicy, with_retry
41
+ from weft_llm.roles import LLMRoles, RoleMapping, UnmappedLLMRoleError
42
+ from weft_llm.scripted import NAME, ScriptedConfig, ScriptedProvider
43
+
44
+
45
+ class Settings(BaseModel):
46
+ """`weft-llm` takes no pack settings — an empty model is still the required shape."""
47
+
48
+ model_config = ConfigDict(frozen=True, extra="forbid")
49
+
50
+
51
+ def register(registrar: PackRegistrar, settings: Settings) -> None:
52
+ """Register `ScriptedProvider` as `"scripted"` for `LLMProvider`.
53
+
54
+ The only plugin this pack ships.
55
+ """
56
+ del settings
57
+ registrar.add(LLMProvider, NAME, ScriptedProvider)
58
+
59
+
60
+ __all__ = [
61
+ "LLM",
62
+ "LLM_CONTRACT_VERSION",
63
+ "Completion",
64
+ "Conversation",
65
+ "LLMClient",
66
+ "LLMProvider",
67
+ "LLMRoles",
68
+ "LoopGuardConfig",
69
+ "Message",
70
+ "MessageRole",
71
+ "ModelRef",
72
+ "NativeStructured",
73
+ "NullSink",
74
+ "OnFailure",
75
+ "Rendered",
76
+ "RetryPolicy",
77
+ "RoleMapping",
78
+ "ScriptedConfig",
79
+ "ScriptedProvider",
80
+ "Settings",
81
+ "TokenChunk",
82
+ "TokenSink",
83
+ "TokenUsage",
84
+ "UnmappedLLMRoleError",
85
+ "detect_generation_loop",
86
+ "find_runtime_match",
87
+ "llm_service",
88
+ "model_ref",
89
+ "register",
90
+ "with_retry",
91
+ ]
@@ -0,0 +1,317 @@
1
+ """The `LLM` service — one code path from a role to an answer, with everything attached.
2
+
3
+ Task **2.10**: the service `weft_llm.contract`'s own docstring promised "one task later",
4
+ "alongside the retry and cascade machinery". It is the assembly point for four things a
5
+ technique author must never write again: resolving a role to a provider and a model, retry,
6
+ the registration seam, and streaming.
7
+
8
+ **Streaming attaches here, and that discharges task 2.17 structurally.**
9
+ `.phase2-design.md` decision 10 and §7: "the `LLM` client **always** calls `provider.stream(...)`,
10
+ accumulates, and emits each chunk to `ctx.require(TokenSink)` tagged with the call's `role`.
11
+ `complete()` still returns a decided `Outcome[Completion]`, decided at return and not when a
12
+ stream is drained (G6)." One code path means "a generator that forgets to stream cannot exist,
13
+ and a streaming twin has nowhere to live" — the donor kept ten `@register_strategy` and ten
14
+ `@register_streaming_strategy` sites symmetric by hand, with no test asserting it, and
15
+ `step-back`'s streaming twin silently became a different technique.
16
+
17
+ **The cost of always streaming, named rather than discovered.** `LLMProvider.stream` yields
18
+ text and nothing else, so a `Completion` built here carries `finish_reason=""` — the vendor's
19
+ own word for why generation stopped does not survive. That is acceptable *because the taxonomy
20
+ carries the same facts as classes*: a content-filter refusal is an `LLMContentFilterError` and
21
+ a truncation is a provider's own concern, so nothing downstream has to string-match a finish
22
+ reason to behave correctly. `LLMProvider.complete` remains the contract member a provider
23
+ implements and a library caller may call directly; this client does not use it.
24
+
25
+ **Every provider call goes through `weft_kernel.seam.wrap`.** The client runs a plugin outside
26
+ `Runner`, which is exactly the case `weft_cli.ask` already set the precedent for: the span,
27
+ the error attribution, the blocking-call guard and the transient strip are the seam's, and
28
+ "if you find yourself writing a span by hand, stop."
29
+
30
+ **Nothing but an `LLMError` escapes.** Anything else a provider lets out is wrapped in
31
+ `LLMProviderFaultError` naming the provider — `.phase2-design.md` §7's first enforcement of
32
+ "a taxonomy nobody catches is documentation". `CancelledError` is a `BaseException` and is
33
+ untouched by every `except Exception` in this file, by construction.
34
+
35
+ **Task 3.10 adds a fifth thing assembled here: the loop-breaker.** `weft_llm.loop_guard.
36
+ detect_generation_loop` needs the whole answer accumulated so far on every call, and `complete`
37
+ already builds exactly that (`parts`, joined) before emitting to the sink — the only place in
38
+ this tree holding that shape on every token, which is why the guard attaches inside `complete`'s
39
+ own accumulation loop rather than living in a `TokenSink` (`donor/study/08-salvage.md` §T1.12,
40
+ lifted per `01` → Phase 3 **Lift**). A detected loop raises `LLMGenerationLoopError` — an
41
+ `LLMPermanentError`, so it takes the same `except LLMError: raise` path a provider's own errors
42
+ do — rather than quietly returning a truncated `Completion`; `weft_cli.cli.run_command` turns
43
+ that raise into `TokenSink.close(reason=...)`, so a reader is told the stream was cut short
44
+ rather than left to mistake it for one that finished cleanly.
45
+ """
46
+
47
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
48
+ from typing import cast
49
+
50
+ from weft_kernel.context import Context
51
+ from weft_kernel.payload import NothingToProduce, Outcome, Produced
52
+ from weft_kernel.registry import Registry, unwrap_factory
53
+ from weft_kernel.seam import wrap
54
+ from weft_llm.contract import LLM, LLMProvider, NativeStructured, TokenSink
55
+ from weft_llm.errors import (
56
+ LLMError,
57
+ LLMGenerationLoopError,
58
+ LLMProviderFaultError,
59
+ NativeStructuredUnsupportedError,
60
+ )
61
+ from weft_llm.loop_guard import LoopGuardConfig, detect_generation_loop
62
+ from weft_llm.models import ModelRef, find_runtime_match, model_ref
63
+ from weft_llm.payload import Completion, Rendered, TokenChunk
64
+ from weft_llm.retry import RetryPolicy, with_retry
65
+ from weft_llm.roles import LLMRoles
66
+
67
+ #: The contract name the seam stamps on every span and every attributed error raised through
68
+ #: this client. Written once, here, rather than at each of the three call sites.
69
+ _CONTRACT = "LLMProvider"
70
+
71
+
72
+ class NullSink:
73
+ """A `TokenSink` that discards. The default, so no plugin ever needs a `try`.
74
+
75
+ `.phase2-design.md` §13 item 7 leaves `PrintingSink` to a Phase 3 sequencing call and
76
+ settles this half: "Phase 2 ships `NullSink` and the client-side emit path." Discarding is
77
+ not a fallback — a run nobody is watching genuinely has nowhere to put tokens, and the
78
+ alternative (no sink at all) would put a `try` in every caller.
79
+ """
80
+
81
+ async def emit(self, chunk: TokenChunk) -> None:
82
+ """Discards `chunk`. Deliberately not buffered — nothing would ever read the buffer."""
83
+ del chunk
84
+
85
+ async def close(self, *, reason: str | None = None) -> None:
86
+ """Nothing was opened. Present because the contract requires it of every sink."""
87
+ del reason
88
+
89
+
90
+ class _Bound:
91
+ """One role's resolved answer: the provider to call, the model to name, who to blame."""
92
+
93
+ def __init__(self, *, provider: LLMProvider, raw: object, ref: ModelRef, distribution: str):
94
+ self.provider = provider
95
+ #: The instance *before* retry wrapped it, kept only so `close` reaches the real one
96
+ #: exactly once when two roles share a provider.
97
+ self.raw = raw
98
+ self.ref = ref
99
+ self.distribution = distribution
100
+
101
+
102
+ class LLMClient:
103
+ """Resolves a role to a provider and a model at call time, and answers through it.
104
+
105
+ Satisfies `weft_llm.contract.LLM` structurally — this class never imports it as a base,
106
+ the same path every plugin in this tree takes with its own contract.
107
+
108
+ **Providers are built once per provider name, not once per role.** Two roles naming the
109
+ same provider with different models share one instance and one connection pool, which is
110
+ the shape `LLMProvider`'s own docstring argues for: "`model` is a per-call argument, not
111
+ constructor state … the same account, many models".
112
+ """
113
+
114
+ def __init__(
115
+ self,
116
+ *,
117
+ registry: Registry,
118
+ roles: LLMRoles,
119
+ retry: RetryPolicy | None = None,
120
+ loop_guard: LoopGuardConfig | None = None,
121
+ ) -> None:
122
+ self._registry = registry
123
+ self._roles = roles
124
+ self._retry = retry if retry is not None else RetryPolicy()
125
+ self._loop_guard = loop_guard if loop_guard is not None else LoopGuardConfig()
126
+ self._bound: dict[str, _Bound] = {}
127
+ #: Every provider name this deployment mapped — what makes a `provider/model` prefix
128
+ #: recognisable as a prefix rather than half of a model id. See `weft_llm.models`.
129
+ self._provider_names = frozenset(mapping.provider for mapping in roles.roles.values())
130
+
131
+ async def complete(self, rendered: Rendered, *, role: str, ctx: Context) -> Outcome[Completion]:
132
+ """Continue `rendered`'s conversation under `role`, streaming every chunk to the sink."""
133
+ bound = self._bind(role)
134
+ sink = ctx.require(TokenSink)
135
+
136
+ async def run() -> Outcome[Completion]:
137
+ parts: list[str] = []
138
+ try:
139
+ async for chunk in bound.provider.stream(
140
+ rendered.conversation, model=bound.ref.model, ctx=ctx
141
+ ):
142
+ parts.append(chunk)
143
+ await sink.emit(TokenChunk(role=role, text=chunk))
144
+ # Task 3.10: `parts` already holds the whole answer accumulated so far —
145
+ # exactly the cumulative-text contract `weft_llm.loop_guard` requires — so
146
+ # this is where the guard attaches rather than inside a `TokenSink`, which
147
+ # only ever sees one chunk at a time. The chunk that revealed the loop has
148
+ # already been emitted above, so a reader still sees it before the stream
149
+ # stops; nothing after it is generated or shown.
150
+ accumulated = "".join(parts)
151
+ if detect_generation_loop(accumulated, config=self._loop_guard):
152
+ raise self._loop_detected(bound, role, accumulated)
153
+ except LLMError:
154
+ raise
155
+ except Exception as fault:
156
+ raise self._fault(bound, role, fault) from fault
157
+ text = "".join(parts)
158
+ if not text:
159
+ # Never an empty `Produced` — the donor trap every contract in this tree
160
+ # documents. A model that answered with nothing did not answer.
161
+ return NothingToProduce(
162
+ reason=(
163
+ f"provider '{bound.ref.provider}' returned no text for role '{role}' "
164
+ f"on model '{bound.ref.model or '(provider default)'}'"
165
+ )
166
+ )
167
+ return Produced(value=Completion(text=text, model=bound.ref.model, finish_reason=""))
168
+
169
+ return await self._sealed(bound, role, run)()
170
+
171
+ async def complete_structured(
172
+ self, rendered: Rendered, schema: Mapping[str, object], *, role: str, ctx: Context
173
+ ) -> Outcome[Completion]:
174
+ """Tier 1 of the cascade: ask the vendor to answer *in* `schema` and check it itself.
175
+
176
+ Not streamed. A partial JSON document displayed as it arrives is noise to a reader and
177
+ unparseable to anything else, and the cascade's caller wants a typed value, not tokens.
178
+ """
179
+ bound = self._bind(role)
180
+ native = bound.provider
181
+ if not isinstance(native, NativeStructured):
182
+ raise NativeStructuredUnsupportedError(
183
+ f"provider '{bound.ref.provider}' (role '{role}') does not offer native "
184
+ f"structured output. Ask `native_structured_available('{role}')` first, or "
185
+ f"call the structured-output cascade, which steps down for you.",
186
+ provider=bound.ref.provider,
187
+ model=bound.ref.model,
188
+ )
189
+
190
+ async def run() -> Outcome[Completion]:
191
+ try:
192
+ return await native.complete_structured(
193
+ rendered.conversation, schema, model=bound.ref.model, ctx=ctx
194
+ )
195
+ except LLMError:
196
+ raise
197
+ except Exception as fault:
198
+ raise self._fault(bound, role, fault) from fault
199
+
200
+ return await self._sealed(bound, role, run)()
201
+
202
+ async def native_structured_available(self, role: str) -> bool:
203
+ """Whether `role`'s provider satisfies `NativeStructured` — derived, never declared."""
204
+ return isinstance(self._bind(role).provider, NativeStructured)
205
+
206
+ async def close(self) -> None:
207
+ """Close every provider this client built, once each, in the order they were built."""
208
+ for bound in self._bound.values():
209
+ await bound.provider.close()
210
+
211
+ # --- resolution ---------------------------------------------------------------------
212
+
213
+ def _bind(self, role: str) -> _Bound:
214
+ """`role` → the provider instance and the model string a call under it uses.
215
+
216
+ Every refusal on this path is loud and names its options: an unmapped role names every
217
+ mapped role (`UnmappedLLMRoleError`), an unregistered provider names every registered
218
+ one (the registry's own `UnknownPluginError`), and a model a provider's declared
219
+ catalogue does not offer names the catalogue (`UnknownModelError`).
220
+ """
221
+ mapping = self._roles.resolve(role)
222
+ entry = self._registry.entry(LLMProvider, mapping.provider)
223
+ ref = model_ref(
224
+ provider=mapping.provider, requested=mapping.model, providers=self._provider_names
225
+ )
226
+ catalogue = _declared_catalogue(entry.factory)
227
+ if catalogue and ref.model:
228
+ ref = find_runtime_match(ref, catalogue)
229
+ cached = self._bound.get(mapping.provider)
230
+ if cached is not None:
231
+ return _Bound(
232
+ provider=cached.provider,
233
+ raw=cached.raw,
234
+ ref=ref,
235
+ distribution=cached.distribution,
236
+ )
237
+ raw = entry.factory(None)
238
+ bound = _Bound(
239
+ provider=with_retry(cast("LLMProvider", raw), self._retry),
240
+ raw=raw,
241
+ ref=ref,
242
+ distribution=entry.distribution,
243
+ )
244
+ self._bound[mapping.provider] = bound
245
+ return bound
246
+
247
+ def _sealed(
248
+ self, bound: _Bound, role: str, run: Callable[[], Awaitable[Outcome[Completion]]]
249
+ ) -> Callable[[], Awaitable[Outcome[Completion]]]:
250
+ """`run`, through the registration seam, attributed to the provider that will answer."""
251
+ return wrap(
252
+ run,
253
+ distribution=bound.distribution,
254
+ contract=_CONTRACT,
255
+ plugin=bound.ref.provider,
256
+ stage=f"llm:{role}",
257
+ )
258
+
259
+ def _fault(self, bound: _Bound, role: str, fault: Exception) -> LLMProviderFaultError:
260
+ return LLMProviderFaultError(
261
+ f"provider '{bound.ref.provider}' (role '{role}') raised "
262
+ f"{type(fault).__name__}: {fault}. That is not an LLMError, so nothing downstream "
263
+ f"could have caught it by class — it is a defect in the provider adapter, not a "
264
+ f"failure mode of the model.",
265
+ provider=bound.ref.provider,
266
+ model=bound.ref.model,
267
+ )
268
+
269
+ def _loop_detected(self, bound: _Bound, role: str, accumulated: str) -> LLMGenerationLoopError:
270
+ return LLMGenerationLoopError(
271
+ f"provider '{bound.ref.provider}' (role '{role}') was generating a repeating span "
272
+ f"and was stopped after {len(accumulated)} characters rather than left to keep "
273
+ f"filling the terminal. This is a loop-breaker for a model that got stuck, not a "
274
+ f"judgment about the content — retrying the identical prompt against the same "
275
+ f"model is likely to loop again; try a different prompt, role, or model.",
276
+ provider=bound.ref.provider,
277
+ model=bound.ref.model,
278
+ )
279
+
280
+
281
+ def llm_service(
282
+ *,
283
+ registry: Registry,
284
+ roles: LLMRoles,
285
+ retry: RetryPolicy | None = None,
286
+ loop_guard: LoopGuardConfig | None = None,
287
+ ) -> LLMClient:
288
+ """Build the run's `LLM`. This pack's own constructor, per `.phase2-design.md` §7.
289
+
290
+ "Each pack builds its own service constructor … so a library caller is not forced through
291
+ the CLI." `weft_cli.run_services.build_services` calls this one and adds the result to the
292
+ run's `ServiceRegistry`; an embedding host application calls it directly with a role table
293
+ it built itself.
294
+ """
295
+ return LLMClient(registry=registry, roles=roles, retry=retry, loop_guard=loop_guard)
296
+
297
+
298
+ def _declared_catalogue(factory: Callable[..., object]) -> Sequence[str]:
299
+ """A provider's declared `models`, read the documented way — through `unwrap_factory`.
300
+
301
+ `functools.partial` does not proxy attribute access, so a pack binding its settings at
302
+ registration (`partial(Provider, settings)` — `weft-store`'s shape) would otherwise
303
+ advertise nothing and the model check would silently never run.
304
+
305
+ A provider declaring nothing gets `()`, which turns the check off rather than refusing
306
+ every model: a name this code cannot check must be passed through, not guessed at.
307
+ """
308
+ declared = getattr(unwrap_factory(factory), "models", ())
309
+ if isinstance(declared, str) or not isinstance(declared, Sequence):
310
+ return ()
311
+ return [str(entry) for entry in cast("Sequence[object]", declared)]
312
+
313
+
314
+ #: Stated so a reader of this module sees that the service Protocol is satisfied structurally,
315
+ #: the same way a plugin satisfies its contract, and so a checker verifies it once here rather
316
+ #: than at every `ServiceRegistry.add(LLM, ...)` call site.
317
+ _: type[LLM] = LLMClient