sonar-eval 0.3.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.
sonar_eval/__init__.py ADDED
@@ -0,0 +1,101 @@
1
+ """sonar-eval - evaluation harness SDK for agentic AI chatbots.
2
+
3
+ Built on sonar-tracing: the harness drives an agent through simulated conversations,
4
+ injects faults into its tool calls, and grades the transcript it pulls back from
5
+ sonar-ingest by conversation_id.
6
+
7
+ from sonar_eval import Orchestrator, TaskConfig, ScenarioConfig
8
+ """
9
+
10
+ from .adapter import (
11
+ BaseBotAdapter,
12
+ BotAdapter,
13
+ Delivered,
14
+ Seed,
15
+ Session,
16
+ TrialContext,
17
+ load_adapter,
18
+ validate_bot_adapter,
19
+ )
20
+ from .assertions import (
21
+ Assertion,
22
+ AssertionEngine,
23
+ AssertionResult,
24
+ Severity,
25
+ max_total_tokens,
26
+ max_turns,
27
+ no_tool_errors,
28
+ tool_was_called,
29
+ )
30
+ from .config import Fidelity, Persona, ScenarioConfig, TaskConfig
31
+ from .grader import Grader, GraderResult
32
+ from .interceptor import (
33
+ Action,
34
+ FaultRule,
35
+ FaultSpec,
36
+ InjectedFault,
37
+ InterceptDecision,
38
+ ProxyInterceptor,
39
+ ToolCall,
40
+ ToolInterceptor,
41
+ serialize_faults,
42
+ )
43
+ from .llm import LLMClient, LLMConfig, Message
44
+ from .metrics import AssertionMetric, ScenarioReport, summarize
45
+ from .orchestrator import Orchestrator, TrialResult
46
+ from .simulator import ConversationLog, Turn, UserSimulator
47
+ from .store import JSONFileStore, NullStore, ResultStore
48
+ from .transcript import IngestClient, ToolInvocation, Transcript
49
+ from .version import __version__
50
+
51
+ __all__ = [
52
+ "Action",
53
+ "Assertion",
54
+ "AssertionEngine",
55
+ "AssertionMetric",
56
+ "AssertionResult",
57
+ "BaseBotAdapter",
58
+ "BotAdapter",
59
+ "ConversationLog",
60
+ "Delivered",
61
+ "FaultRule",
62
+ "FaultSpec",
63
+ "Fidelity",
64
+ "Grader",
65
+ "GraderResult",
66
+ "IngestClient",
67
+ "InjectedFault",
68
+ "InterceptDecision",
69
+ "JSONFileStore",
70
+ "LLMClient",
71
+ "LLMConfig",
72
+ "Message",
73
+ "NullStore",
74
+ "Orchestrator",
75
+ "Persona",
76
+ "ProxyInterceptor",
77
+ "ResultStore",
78
+ "ScenarioConfig",
79
+ "ScenarioReport",
80
+ "Seed",
81
+ "Session",
82
+ "Severity",
83
+ "TaskConfig",
84
+ "ToolCall",
85
+ "ToolInterceptor",
86
+ "ToolInvocation",
87
+ "Transcript",
88
+ "TrialContext",
89
+ "TrialResult",
90
+ "Turn",
91
+ "UserSimulator",
92
+ "__version__",
93
+ "load_adapter",
94
+ "max_total_tokens",
95
+ "max_turns",
96
+ "no_tool_errors",
97
+ "serialize_faults",
98
+ "summarize",
99
+ "tool_was_called",
100
+ "validate_bot_adapter",
101
+ ]
sonar_eval/adapter.py ADDED
@@ -0,0 +1,122 @@
1
+ """The bot adapter contract.
2
+
3
+ One thin interface the developer implements once per bot. It absorbs the stack
4
+ differences (auth, DB, transport) so the rest of the harness stays generic, and it is
5
+ where per-trial isolation and seeding happen.
6
+
7
+ Two things are threaded through every trial:
8
+
9
+ conversation_id minted by the harness via ``sonar_tracing.new_conversation()``. The
10
+ adapter must make the bot stamp it on its spans - for a service like
11
+ Cafeina that means POSTing it to an app-internal endpoint so the app
12
+ wraps its turns in ``tracing.identity(conversation_id=...)``. It is the
13
+ join key the grader later uses to pull the transcript back from ingest.
14
+ faults a FaultSpec. In mocked fidelity the adapter routes its mocks through a
15
+ ProxyInterceptor built from it; in real fidelity it posts
16
+ ``serialize_faults(faults)`` to the app alongside conversation_id.
17
+
18
+ ``send()`` returns only what a human user would see. The tool-call transcript is NOT
19
+ returned here - it is pulled from sonar-ingest by conversation_id, which keeps the user
20
+ simulator honest (it can only ever see delivered messages).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib
26
+ from dataclasses import dataclass, field
27
+ from typing import Any, Protocol
28
+
29
+ from .config import Fidelity
30
+ from .interceptor import FaultSpec
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Seed:
35
+ """Project-defined seed state: authorizations, DB rows, stored data.
36
+
37
+ Opaque to the harness; the adapter is the only thing that interprets ``data``.
38
+ """
39
+
40
+ data: dict[str, Any] = field(default_factory=dict)
41
+
42
+
43
+ @dataclass
44
+ class Session:
45
+ """Opaque per-trial handle the adapter owns. Carries the join key for convenience."""
46
+
47
+ conversation_id: str
48
+ handle: Any = None
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class Delivered:
53
+ """Exactly what a human user would see for one turn."""
54
+
55
+ messages: tuple[str, ...] = ()
56
+ media: tuple[Any, ...] = ()
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class TrialContext:
61
+ conversation_id: str
62
+ seed: Seed
63
+ faults: FaultSpec
64
+ fidelity: Fidelity
65
+
66
+
67
+ class BotAdapter(Protocol):
68
+ name: str
69
+
70
+ async def setup_trial(self, ctx: TrialContext) -> Session:
71
+ """Isolate + seed state for one trial, wire fault injection, return a handle."""
72
+
73
+ async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
74
+ """Deliver one user turn; return only what the user would see back."""
75
+
76
+ async def teardown_trial(self, session: Session) -> None:
77
+ """Reset in lockstep. Must be safe to call after a failed setup."""
78
+
79
+
80
+ class BaseBotAdapter:
81
+ """Optional convenience base so a minimal adapter is a few lines."""
82
+
83
+ name: str = "unnamed"
84
+
85
+ async def setup_trial(self, ctx: TrialContext) -> Session:
86
+ return Session(conversation_id=ctx.conversation_id)
87
+
88
+ async def send(self, session: Session, user_msg: str, media: Any = None) -> Delivered:
89
+ return Delivered()
90
+
91
+ async def teardown_trial(self, session: Session) -> None:
92
+ return None
93
+
94
+
95
+ _REQUIRED_METHODS = ("setup_trial", "send", "teardown_trial")
96
+
97
+
98
+ def validate_bot_adapter(candidate: Any) -> None:
99
+ """Structural check with a useful message.
100
+
101
+ ``runtime_checkable`` Protocols cannot verify the ``name`` attribute, and a missing
102
+ async method would otherwise surface deep inside the trial loop.
103
+ """
104
+ if not isinstance(getattr(candidate, "name", None), str):
105
+ raise TypeError(f"{candidate!r} is missing a string `name` attribute")
106
+ not_callable = [m for m in _REQUIRED_METHODS if not callable(getattr(candidate, m, None))]
107
+ if not_callable:
108
+ raise TypeError(f"{candidate!r} is missing methods: {not_callable}")
109
+
110
+
111
+ def load_adapter(path: str) -> BotAdapter:
112
+ """Load an adapter from a ``module.path:attribute`` string and validate it.
113
+
114
+ The attribute may be an instance or a zero-arg class/factory.
115
+ """
116
+ module_path, _, attr = path.partition(":")
117
+ if not attr:
118
+ raise ValueError(f"adapter path must be 'module:attr', got {path!r}")
119
+ obj = getattr(importlib.import_module(module_path), attr)
120
+ candidate = obj() if isinstance(obj, type) else obj
121
+ validate_bot_adapter(candidate)
122
+ return candidate
@@ -0,0 +1,81 @@
1
+ """Programmatic (tier-1) assertions over a pulled transcript.
2
+
3
+ Each assertion carries a Severity that decides how it is aggregated across k trials:
4
+ safety-critical assertions must hold on every trial (pass^k == 1.0); capability assertions
5
+ need only hold once (pass@k). Checks are ordinary callables so projects add their own.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+ from .simulator import ConversationLog
15
+ from .transcript import Transcript
16
+
17
+
18
+ class Severity(StrEnum):
19
+ SAFETY_CRITICAL = "safety_critical"
20
+ CAPABILITY = "capability"
21
+
22
+
23
+ Check = Callable[[Transcript, ConversationLog], bool]
24
+
25
+
26
+ @dataclass
27
+ class Assertion:
28
+ name: str
29
+ severity: Severity
30
+ check: Check
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class AssertionResult:
35
+ name: str
36
+ severity: Severity
37
+ passed: bool
38
+ detail: str = ""
39
+
40
+
41
+ class AssertionEngine:
42
+ def __init__(self, assertions: list[Assertion]) -> None:
43
+ self._assertions = assertions
44
+
45
+ def evaluate(self, transcript: Transcript, log: ConversationLog) -> list[AssertionResult]:
46
+ results = []
47
+ for assertion in self._assertions:
48
+ try:
49
+ passed = bool(assertion.check(transcript, log))
50
+ detail = ""
51
+ except Exception as exc: # noqa: BLE001 - a raising check is a failed assertion
52
+ passed = False
53
+ detail = f"{type(exc).__name__}: {exc}"
54
+ results.append(
55
+ AssertionResult(
56
+ name=assertion.name,
57
+ severity=assertion.severity,
58
+ passed=passed,
59
+ detail=detail,
60
+ )
61
+ )
62
+ return results
63
+
64
+
65
+ # --- a few common checks --------------------------------------------------------------
66
+
67
+
68
+ def tool_was_called(tool_name: str) -> Check:
69
+ return lambda t, _log: any(c.name == tool_name for c in t.tool_calls)
70
+
71
+
72
+ def no_tool_errors() -> Check:
73
+ return lambda t, _log: all(c.success is not False for c in t.tool_calls)
74
+
75
+
76
+ def max_turns(limit: int) -> Check:
77
+ return lambda _t, log: log.turns_taken <= limit
78
+
79
+
80
+ def max_total_tokens(limit: int) -> Check:
81
+ return lambda t, _log: sum(t.tokens()) <= limit
sonar_eval/config.py ADDED
@@ -0,0 +1,82 @@
1
+ """Task- and scenario-level configuration.
2
+
3
+ TaskConfig holds global defaults; each ScenarioConfig overrides what it needs. Assertions
4
+ are attached per scenario with a Severity that decides how they are graded (safety-critical
5
+ => pass^k must be 1.0; capability => pass@k).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from enum import StrEnum
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ from .interceptor import FaultSpec
15
+ from .llm import LLMConfig
16
+
17
+ if TYPE_CHECKING:
18
+ from .assertions import Assertion
19
+
20
+
21
+ class Fidelity(StrEnum):
22
+ REAL = "real" # exercise the bot's real dependencies
23
+ MOCKED = "mocked" # fast, deterministic, tests prompt logic not integration
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class Persona:
28
+ name: str
29
+ instructions: str
30
+
31
+
32
+ @dataclass
33
+ class ScenarioConfig:
34
+ name: str
35
+ user_instructions: str
36
+ assertions: list[Assertion] = field(default_factory=list)
37
+ k: int = 5
38
+ fidelity: Fidelity = Fidelity.MOCKED
39
+ faults: FaultSpec = field(default_factory=FaultSpec)
40
+ persona: Persona | None = None
41
+ seed: dict[str, Any] = field(default_factory=dict)
42
+ params: dict[str, Any] = field(default_factory=dict)
43
+ grader_rubric: str | None = None
44
+ user_llm: LLMConfig | None = None
45
+ grader_llm: LLMConfig | None = None
46
+ termination_signal: str = "[[END]]"
47
+ max_turns: int = 20
48
+
49
+
50
+ @dataclass
51
+ class TaskConfig:
52
+ """Global defaults shared across scenarios."""
53
+
54
+ ingest_base_url: str
55
+ tenant: str
56
+ user_llm: LLMConfig
57
+ grader_llm: LLMConfig
58
+ # Full-content project so the grader sees unredacted tool I/O rather than previews.
59
+ project: str = "eval-fullcontent"
60
+ api_key: str | None = None
61
+ api_key_header: str = "x-sonar-key"
62
+ default_k: int = 5
63
+ scenarios: list[ScenarioConfig] = field(default_factory=list)
64
+
65
+ def resolve(self, scenario: ScenarioConfig) -> ScenarioConfig:
66
+ """Fill scenario gaps from task-level defaults."""
67
+ return ScenarioConfig(
68
+ name=scenario.name,
69
+ user_instructions=scenario.user_instructions,
70
+ assertions=scenario.assertions,
71
+ k=scenario.k or self.default_k,
72
+ fidelity=scenario.fidelity,
73
+ faults=scenario.faults,
74
+ persona=scenario.persona,
75
+ seed=scenario.seed,
76
+ params=scenario.params,
77
+ grader_rubric=scenario.grader_rubric,
78
+ user_llm=scenario.user_llm or self.user_llm,
79
+ grader_llm=scenario.grader_llm or self.grader_llm,
80
+ termination_signal=scenario.termination_signal,
81
+ max_turns=scenario.max_turns,
82
+ )
sonar_eval/grader.py ADDED
@@ -0,0 +1,80 @@
1
+ """LLM rubric grader (tier 2).
2
+
3
+ Consumes the full transcript - delivered messages *and* the tool-call view pulled from
4
+ ingest - and returns a calibrated score + reasoning. The score is what a rubric can judge
5
+ that a programmatic assertion cannot (tone, helpfulness, whether a refusal was appropriate).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+
14
+ from .llm import LLMClient, LLMConfig, Message
15
+ from .simulator import ConversationLog
16
+ from .transcript import Transcript
17
+
18
+ _SYSTEM = """You are an evaluator scoring a chatbot conversation against a rubric.
19
+ Return ONLY a JSON object: {"score": <float 0..1>, "reasoning": "<short explanation>"}."""
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class GraderResult:
24
+ score: float
25
+ reasoning: str
26
+ passed: bool
27
+
28
+
29
+ Calibration = Callable[[GraderResult], GraderResult]
30
+
31
+
32
+ class Grader:
33
+ def __init__(
34
+ self,
35
+ client: LLMClient,
36
+ config: LLMConfig,
37
+ rubric: str,
38
+ *,
39
+ pass_threshold: float = 0.7,
40
+ calibration: Calibration | None = None,
41
+ ) -> None:
42
+ self._client = client
43
+ self._config = config
44
+ self._rubric = rubric
45
+ self._threshold = pass_threshold
46
+ self._calibration = calibration
47
+
48
+ async def grade(self, transcript: Transcript, log: ConversationLog) -> GraderResult:
49
+ prompt = self._build_prompt(transcript, log)
50
+ raw = await self._client.complete(self._config, _SYSTEM, [Message("user", prompt)])
51
+ score, reasoning = _parse(raw)
52
+ result = GraderResult(score=score, reasoning=reasoning, passed=score >= self._threshold)
53
+ if self._calibration is not None:
54
+ result = self._calibration(result)
55
+ return result
56
+
57
+ def _build_prompt(self, transcript: Transcript, log: ConversationLog) -> str:
58
+ lines = [f"RUBRIC:\n{self._rubric}", "", "CONVERSATION (what the user saw):"]
59
+ for turn in log.turns:
60
+ lines.append(f" user: {turn.user}")
61
+ for msg in turn.delivered:
62
+ lines.append(f" bot: {msg}")
63
+ lines.append("")
64
+ lines.append("TOOL CALLS (bot internals):")
65
+ for call in transcript.tool_calls:
66
+ status = "ok" if call.success else (call.error_type or "failed")
67
+ lines.append(f" {call.name} -> {status}")
68
+ return "\n".join(lines)
69
+
70
+
71
+ def _parse(raw: str) -> tuple[float, str]:
72
+ start, end = raw.find("{"), raw.rfind("}")
73
+ if start != -1 and end != -1 and end > start:
74
+ try:
75
+ data = json.loads(raw[start : end + 1])
76
+ score = float(data.get("score", 0.0))
77
+ return max(0.0, min(1.0, score)), str(data.get("reasoning", ""))
78
+ except (ValueError, TypeError):
79
+ pass
80
+ return 0.0, f"unparseable grader output: {raw[:200]}"
@@ -0,0 +1,152 @@
1
+ """Tool/API fault injection.
2
+
3
+ sonar-tracing only *observes* tool spans; it never sits in the execution path, so
4
+ interception has to live at the dependency boundary the bot calls through. This module
5
+ provides a declarative fault spec plus the two mechanisms the harness supports:
6
+
7
+ mocked fidelity ProxyInterceptor - harness-owned, in front of mocked dependencies.
8
+ real fidelity serialize_faults() - posted to the app's internal endpoint by the
9
+ adapter, so the app's own tool dispatch can honour the rules.
10
+
11
+ Rules are declarative so trials are reproducible (pass^k needs determinism). A content
12
+ predicate (``when``) is available for the "fail only if it tried to charge >$100" case,
13
+ but predicates only run in mocked fidelity - see serialize_faults().
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass, field, replace
20
+ from enum import StrEnum
21
+ from typing import Any, Protocol
22
+
23
+
24
+ class Action(StrEnum):
25
+ ERROR = "error" # raise an exception in place of the real call
26
+ TIMEOUT = "timeout" # raise a timeout-shaped exception
27
+ REPLACE_OUTPUT = "replace_output" # return payload instead of the real result
28
+ LATENCY = "latency" # sleep payload seconds, then pass through
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class ToolCall:
33
+ target: str
34
+ args: dict[str, Any] = field(default_factory=dict)
35
+ call_index: int = 0 # 0-based nth invocation of this target in the conversation
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class FaultRule:
40
+ target: str
41
+ action: Action
42
+ when_call_index: int | None = None
43
+ when: Callable[[ToolCall], bool] | None = None
44
+ payload: Any = None
45
+
46
+ def matches(self, call: ToolCall) -> bool:
47
+ if call.target != self.target:
48
+ return False
49
+ if self.when_call_index is not None and call.call_index != self.when_call_index:
50
+ return False
51
+ return self.when is None or self.when(call)
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class FaultSpec:
56
+ rules: tuple[FaultRule, ...] = ()
57
+
58
+
59
+ class DecisionKind(StrEnum):
60
+ PASSTHROUGH = "passthrough"
61
+ REPLACE = "replace"
62
+ RAISE = "raise"
63
+ DELAY = "delay"
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class InterceptDecision:
68
+ kind: DecisionKind
69
+ value: Any = None
70
+
71
+ @classmethod
72
+ def passthrough(cls) -> InterceptDecision:
73
+ return cls(DecisionKind.PASSTHROUGH)
74
+
75
+ @classmethod
76
+ def replace(cls, output: Any) -> InterceptDecision:
77
+ return cls(DecisionKind.REPLACE, output)
78
+
79
+ @classmethod
80
+ def raise_(cls, exc: BaseException) -> InterceptDecision:
81
+ return cls(DecisionKind.RAISE, exc)
82
+
83
+ @classmethod
84
+ def delay(cls, seconds: float) -> InterceptDecision:
85
+ return cls(DecisionKind.DELAY, seconds)
86
+
87
+
88
+ class ToolInterceptor(Protocol):
89
+ def on_tool_call(self, call: ToolCall) -> InterceptDecision: ...
90
+
91
+
92
+ class InjectedFault(RuntimeError):
93
+ """Raised by the proxy for Action.ERROR / Action.TIMEOUT."""
94
+
95
+
96
+ def _decision_for(rule: FaultRule) -> InterceptDecision:
97
+ if rule.action is Action.ERROR:
98
+ return InterceptDecision.raise_(InjectedFault(f"injected error for {rule.target}"))
99
+ if rule.action is Action.TIMEOUT:
100
+ return InterceptDecision.raise_(TimeoutError(f"injected timeout for {rule.target}"))
101
+ if rule.action is Action.REPLACE_OUTPUT:
102
+ return InterceptDecision.replace(rule.payload)
103
+ if rule.action is Action.LATENCY:
104
+ return InterceptDecision.delay(float(rule.payload or 0.0))
105
+ return InterceptDecision.passthrough()
106
+
107
+
108
+ class ProxyInterceptor:
109
+ """In-process interceptor for mocked-fidelity scenarios.
110
+
111
+ Construct one per trial; it owns per-target call counters so ``when_call_index``
112
+ is evaluated against the invocation order it observes, not a value the caller has
113
+ to compute.
114
+ """
115
+
116
+ def __init__(self, spec: FaultSpec) -> None:
117
+ self._spec = spec
118
+ self._counts: dict[str, int] = {}
119
+
120
+ def on_tool_call(self, call: ToolCall) -> InterceptDecision:
121
+ index = self._counts.get(call.target, 0)
122
+ self._counts[call.target] = index + 1
123
+ resolved = replace(call, call_index=index)
124
+ for rule in self._spec.rules:
125
+ if rule.matches(resolved):
126
+ return _decision_for(rule)
127
+ return InterceptDecision.passthrough()
128
+
129
+
130
+ def serialize_faults(spec: FaultSpec) -> list[dict[str, Any]]:
131
+ """Serialise a FaultSpec for the real-fidelity adapter hook.
132
+
133
+ Predicate rules cannot cross a process boundary, so a ``when`` predicate is a hard
134
+ error here rather than a silent no-op - silently dropping it would make pass^k
135
+ non-reproducible in a way the author never sees. Use mocked fidelity for predicates.
136
+ """
137
+ wire: list[dict[str, Any]] = []
138
+ for rule in spec.rules:
139
+ if rule.when is not None:
140
+ raise ValueError(
141
+ f"fault rule for {rule.target!r} uses a `when` predicate, which only runs "
142
+ "in mocked fidelity; use `when_call_index` for real-fidelity scenarios"
143
+ )
144
+ wire.append(
145
+ {
146
+ "target": rule.target,
147
+ "action": rule.action.value,
148
+ "when_call_index": rule.when_call_index,
149
+ "payload": rule.payload,
150
+ }
151
+ )
152
+ return wire
sonar_eval/llm.py ADDED
@@ -0,0 +1,30 @@
1
+ """LLM abstraction for the user simulator and grader.
2
+
3
+ The harness stays provider-agnostic: it never imports a vendor SDK. A project supplies
4
+ one ``LLMClient`` and the harness drives both the user simulator and the grader through
5
+ it. Defaulting to the latest Claude models is a project decision, made in the client the
6
+ project wires in - not baked in here.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from typing import Protocol
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Message:
17
+ role: str # "system" | "user" | "assistant"
18
+ content: str
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class LLMConfig:
23
+ model: str
24
+ temperature: float = 1.0
25
+ max_tokens: int = 1024
26
+
27
+
28
+ class LLMClient(Protocol):
29
+ async def complete(self, config: LLMConfig, system: str, messages: list[Message]) -> str:
30
+ """Return the assistant completion text for ``messages`` under ``system``."""