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.
- weft_llm-1.0.0/.gitignore +78 -0
- weft_llm-1.0.0/LICENSE +21 -0
- weft_llm-1.0.0/NOTICE +24 -0
- weft_llm-1.0.0/PKG-INFO +9 -0
- weft_llm-1.0.0/pyproject.toml +16 -0
- weft_llm-1.0.0/src/weft_llm/__init__.py +91 -0
- weft_llm-1.0.0/src/weft_llm/client.py +317 -0
- weft_llm-1.0.0/src/weft_llm/contract.py +158 -0
- weft_llm-1.0.0/src/weft_llm/errors.py +229 -0
- weft_llm-1.0.0/src/weft_llm/loop_guard.py +245 -0
- weft_llm-1.0.0/src/weft_llm/models.py +159 -0
- weft_llm-1.0.0/src/weft_llm/payload.py +155 -0
- weft_llm-1.0.0/src/weft_llm/py.typed +0 -0
- weft_llm-1.0.0/src/weft_llm/retry.py +179 -0
- weft_llm-1.0.0/src/weft_llm/roles.py +80 -0
- weft_llm-1.0.0/src/weft_llm/scripted.py +99 -0
|
@@ -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.
|
weft_llm-1.0.0/PKG-INFO
ADDED
|
@@ -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
|