harness-common 0.3.1__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.
@@ -0,0 +1,57 @@
1
+ """common — harness-neutral shared code and runtime identities.
2
+
3
+ The harnesses (e2e / eval / perf / trace / trajectory) depend on this package but never on each
4
+ other; only genuinely neutral code lives here (no harness domain concept leaks in).
5
+
6
+ The canonical case model — `Case` / `CaseSet` / `FacetSchema` and the `Face` / `FACES`
7
+ judgment-face enum — lives upstream in `spec_case.model` / `spec_case.facets`
8
+ (the spec-case package, `[model]` extra): the case format is the asset layer's contract,
9
+ and this repo is one of its runners. Import those names from `spec_case` directly.
10
+
11
+ `Forge` and `Repository` identify where source lives; `Product` names a business product;
12
+ `Component` identifies a buildable unit within a Repository. `Environment` identifies where
13
+ execution happens. `Service` identifies a Component's runtime presence in one Environment.
14
+ `Workload` locates a declared runtime carrier; a Service can reference multiple workloads.
15
+ `Operation` identifies a capability exposed by a Service; `HttpOperation` adds its HTTP
16
+ transport contract. An `Execution` groups domain-defined work such as an e2e CaseRun or
17
+ a perf Trial; each `OperationRun` owns one raw `Outcome`. Domain engines own scheduling
18
+ details. A domain `Reducer` projects the recorded Outcomes into one or more
19
+ `Artifact` objects without calling the tested Service again. `Deployment` records an
20
+ attempt applied through a `Deployer`.
21
+ `Experiment` names reproducible verification intent; `ExperimentRun` records one execution
22
+ and references its `Artifact` outputs.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from harness_common.artifact import Artifact as Artifact
28
+ from harness_common.component import Component as Component
29
+ from harness_common.deployment import Deployer as Deployer
30
+ from harness_common.deployment import Deployment as Deployment
31
+ from harness_common.environment import Environment as Environment
32
+ from harness_common.environment import KubernetesEnvironment as KubernetesEnvironment
33
+ from harness_common.environment import HostEnvironment as HostEnvironment
34
+ from harness_common.host import Host as Host
35
+ from harness_common.environment import EnvironmentFacts as EnvironmentFacts
36
+ from harness_common.environment import EnvironmentSnapshot as EnvironmentSnapshot
37
+ from harness_common.execution import Execution as Execution
38
+ from harness_common.experiment import Experiment as Experiment
39
+ from harness_common.experiment import ExperimentRun as ExperimentRun
40
+ from harness_common.forge import Forge as Forge
41
+ from harness_common.operation import HttpOperation as HttpOperation
42
+ from harness_common.operation import Operation as Operation
43
+ from harness_common.operation import OperationRun as OperationRun
44
+ from harness_common.outcome import Outcome as Outcome
45
+ from harness_common.product import Product as Product
46
+ from harness_common.repository import Repository as Repository
47
+ from harness_common.reducer import Reducer as Reducer
48
+ from harness_common.service import Service as Service
49
+ from harness_common.workload import KubernetesWorkload as KubernetesWorkload
50
+ from harness_common.workload import Workload as Workload
51
+
52
+ from harness_common.client import Client as Client
53
+ from harness_common.client import ClientManager as ClientManager
54
+ from harness_common.client import ClientProvider as ClientProvider
55
+ from harness_common.client import DataSource as DataSource
56
+ from harness_common.client import ServiceDataSource as ServiceDataSource
57
+ from harness_common.client import data_source_key as data_source_key
@@ -0,0 +1,17 @@
1
+ """A durable output produced by an ExperimentRun."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Artifact:
10
+ """A named domain result referenced relative to its run directory.
11
+
12
+ The producing harness owns the content and schema. Reports render one or more Artifacts
13
+ without becoming a new source of execution facts.
14
+ """
15
+
16
+ name: str
17
+ path: str
@@ -0,0 +1,136 @@
1
+ """Execution-scoped, asynchronous client ownership.
2
+
3
+ DataSource keys describe configuration, not object identity. Consumers receive a
4
+ ClientProvider; only the root execution owns and disposes the ClientManager.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import hashlib
11
+ import json
12
+ from dataclasses import dataclass
13
+ from typing import Generic, Protocol, TypeVar, cast
14
+
15
+ from harness_common.service import Service
16
+
17
+
18
+ class Client(Protocol):
19
+ async def initialize(self) -> None: ...
20
+ async def dispose(self) -> None: ...
21
+
22
+
23
+ C = TypeVar("C", bound=Client, covariant=True)
24
+
25
+
26
+ class DataSource(Protocol[C]):
27
+ @property
28
+ def key(self) -> str: ...
29
+ def create_client(self, clients: ClientProvider) -> C: ...
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class ServiceDataSource(Generic[C]):
34
+ """One access association, not a platform resource or a client owner.
35
+
36
+ A Service can have zero or many associations; the same source can be shared
37
+ by multiple Services. Platform workload mappings belong to deployment config.
38
+ Use clients.get(binding.source) so logical identity never fragments client reuse.
39
+ """
40
+
41
+ service: Service
42
+ source: DataSource[C]
43
+
44
+
45
+ class ClientProvider(Protocol):
46
+ async def get(self, source: DataSource[C]) -> C: ...
47
+
48
+
49
+ def data_source_key(protocol: str, configuration: object) -> str:
50
+ """Hash JSON-compatible configuration, including credentials, without exposing it."""
51
+ canonical = json.dumps(
52
+ configuration, sort_keys=True, separators=(",", ":"), allow_nan=False
53
+ )
54
+ return protocol + ":" + hashlib.sha256(canonical.encode()).hexdigest()
55
+
56
+
57
+ class ClientManager:
58
+ """Share initialized clients within one event loop and close them at root exit.
59
+
60
+ Initialize dependencies through the supplied provider before publishing a
61
+ client as ready. Reverse readiness order then closes consumers first.
62
+ Join all command work before leaving the root context: borrowed clients are
63
+ invalid after disposal. Managers cannot be reopened or shared across loops.
64
+ """
65
+
66
+ def __init__(self) -> None:
67
+ self._pending: dict[str, asyncio.Task[Client]] = {}
68
+ self._ready: list[Client] = []
69
+ self._disposal: asyncio.Task[None] | None = None
70
+ self._cleanup_errors: list[BaseException] = []
71
+ self._unclean: set[str] = set()
72
+
73
+ async def get(self, source: DataSource[C]) -> C:
74
+ if self._disposal is not None:
75
+ raise RuntimeError("client manager is disposed")
76
+ key = source.key
77
+ task = self._pending.get(key)
78
+ if task is None:
79
+ # Store before the task can run; factories can themselves request dependencies.
80
+ task = asyncio.create_task(self._initialize(source))
81
+ self._pending[key] = task
82
+ task.add_done_callback(lambda done: self._settled(key, done))
83
+ # A cancelled consumer must not cancel initialization needed by other consumers.
84
+ return cast(C, await asyncio.shield(task))
85
+
86
+ def _settled(self, key: str, task: asyncio.Task[Client]) -> None:
87
+ failed = task.cancelled() or task.exception() is not None
88
+ if failed and key not in self._unclean and self._pending.get(key) is task:
89
+ del self._pending[key]
90
+
91
+ async def _initialize(self, source: DataSource[Client]) -> Client:
92
+ client = source.create_client(self)
93
+ try:
94
+ await client.initialize()
95
+ self._ready.append(client)
96
+ return client
97
+ except BaseException as error:
98
+ try:
99
+ await client.dispose()
100
+ except BaseException as cleanup:
101
+ self._cleanup_errors.append(cleanup)
102
+ self._unclean.add(source.key)
103
+ raise BaseExceptionGroup(
104
+ "client initialization and cleanup failed", [error, cleanup]
105
+ ) from None
106
+ raise
107
+
108
+ async def dispose(self) -> None:
109
+ """Idempotent, concurrent-safe cleanup; cancellation of a waiter cannot stop it."""
110
+ if self._disposal is None:
111
+ self._disposal = asyncio.create_task(self._dispose())
112
+ await asyncio.shield(self._disposal)
113
+
114
+ async def _dispose(self) -> None:
115
+ pending = list(self._pending.values())
116
+ for task in pending:
117
+ if not task.done():
118
+ task.cancel()
119
+ await asyncio.gather(*pending, return_exceptions=True)
120
+ self._pending.clear()
121
+ for client in reversed(self._ready):
122
+ try:
123
+ await client.dispose()
124
+ except BaseException as error:
125
+ self._cleanup_errors.append(error)
126
+ self._ready.clear()
127
+ if self._cleanup_errors:
128
+ raise BaseExceptionGroup("client cleanup failed", self._cleanup_errors)
129
+
130
+ async def __aenter__(self) -> ClientManager:
131
+ if self._disposal is not None:
132
+ raise RuntimeError("client manager is disposed")
133
+ return self
134
+
135
+ async def __aexit__(self, *_: object) -> None:
136
+ await self.dispose()
@@ -0,0 +1,15 @@
1
+ """Stable identity of one buildable component within a code repository."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from harness_common.repository import Repository
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class Component:
12
+ """A named component owned by a repository; one repository may own many."""
13
+
14
+ repository: Repository
15
+ name: str
@@ -0,0 +1,31 @@
1
+ """Deployment records and the port that applies them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
7
+ from typing import Generic, TypeVar
8
+
9
+ from harness_common.service import Service
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class Deployment:
14
+ """One attempt to create or update a Service from its Component."""
15
+
16
+ service: Service
17
+
18
+
19
+ DeploymentT = TypeVar("DeploymentT", bound=Deployment)
20
+
21
+
22
+ class Deployer(ABC, Generic[DeploymentT]):
23
+ """Port implemented by a concrete deployment mechanism."""
24
+
25
+ @abstractmethod
26
+ async def deploy(self, deployment: DeploymentT) -> None:
27
+ """Apply one Deployment and return after the Service is ready."""
28
+
29
+ async def teardown(self) -> None:
30
+ """Restore the baseline when the concrete deployer supports it."""
31
+ return None
@@ -0,0 +1,97 @@
1
+ """Deployment environment identity, optional access host and observed evidence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import ClassVar
7
+
8
+ from harness_common.host import Host, parse_host
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Environment:
13
+ """A named environment in which Components have running Service instances.
14
+
15
+ Host is optional. When omitted, operations use the current execution host.
16
+ It does not identify a Kubernetes node or dictate where the case runner runs.
17
+ """
18
+
19
+ name: str
20
+ host: Host | None = field(default=None, kw_only=True)
21
+ kind: ClassVar[str] = "generic"
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class HostEnvironment(Environment):
26
+ """A target running directly on the environment's host."""
27
+
28
+ kind: ClassVar[str] = "host"
29
+
30
+
31
+ @dataclass(frozen=True, slots=True)
32
+ class KubernetesEnvironment(Environment):
33
+ """A cluster accessed from Host; kubeconfig is a path on that host."""
34
+
35
+ kubeconfig: str = ""
36
+ context: str | None = None
37
+ kind: ClassVar[str] = "kubernetes"
38
+
39
+
40
+ @dataclass
41
+ class EnvironmentFacts:
42
+ """Observed facts with provenance. Missing values mean unknown, not false."""
43
+
44
+ source: str = ""
45
+ observed_at: str = ""
46
+ values: dict[str, str] = field(default_factory=dict)
47
+
48
+
49
+ @dataclass
50
+ class EnvironmentSnapshot:
51
+ """Run evidence; access configuration and credentials are deliberately absent.
52
+
53
+ Runner and target facts are separate: a local runner can test a remote Pod.
54
+ Profiles select project-owned conditions and never substitute for observation.
55
+ """
56
+
57
+ name: str
58
+ kind: str
59
+ profile: str = "default"
60
+ revision: str = ""
61
+ host_name: str = ""
62
+ host: EnvironmentFacts | None = None
63
+ runner: EnvironmentFacts = field(default_factory=EnvironmentFacts)
64
+ target: EnvironmentFacts = field(default_factory=EnvironmentFacts)
65
+
66
+
67
+ def parse_environment(data: dict) -> Environment:
68
+ """Parse the shared environment wire format, including legacy Kubernetes input."""
69
+ if not isinstance(data, dict):
70
+ raise ValueError("environment must be a mapping")
71
+ unknown = set(data) - {"name", "kind", "host", "kubeconfig", "context"}
72
+ if unknown:
73
+ raise ValueError(
74
+ f"environment contains unknown field(s): {', '.join(sorted(unknown))}"
75
+ )
76
+ if any(
77
+ value is not None and not isinstance(value, str)
78
+ for key, value in data.items()
79
+ if key != "host"
80
+ ):
81
+ raise ValueError("environment fields except host must be strings")
82
+ kind = data.get("kind") or (
83
+ "kubernetes" if data.get("kubeconfig") or data.get("context") else "generic"
84
+ )
85
+ if kind not in {"generic", "host", "kubernetes"}:
86
+ raise ValueError(f"unknown environment kind {kind!r}")
87
+ host = parse_host(data["host"]) if data.get("host") is not None else None
88
+ name = data.get("name") or ""
89
+ if kind == "kubernetes":
90
+ return KubernetesEnvironment(
91
+ name, data.get("kubeconfig") or "", data.get("context"), host=host
92
+ )
93
+ if data.get("kubeconfig") or data.get("context"):
94
+ raise ValueError(f"{kind} environment contains Kubernetes access fields")
95
+ if kind == "host":
96
+ return HostEnvironment(name, host=host)
97
+ return Environment(name, host=host)
@@ -0,0 +1,25 @@
1
+ """Harness-neutral grouping of related OperationRuns."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Generic, TypeVar
7
+
8
+ from harness_common.operation import OperationRun
9
+ from harness_common.outcome import Outcome
10
+
11
+
12
+ OutcomeT = TypeVar("OutcomeT", bound=Outcome)
13
+
14
+
15
+ @dataclass(kw_only=True)
16
+ class Execution(Generic[OutcomeT]):
17
+ """One domain-defined unit of work within an ExperimentRun.
18
+
19
+ E2E and perf intentionally organize Operations differently. The shared
20
+ model records only the stable result: an identity and the OperationRuns
21
+ grouped by that unit of work.
22
+ """
23
+
24
+ id: str
25
+ operation_runs: list[OperationRun[OutcomeT]] = field(default_factory=list)
@@ -0,0 +1,60 @@
1
+ """Harness-neutral experiment identity and run artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from collections.abc import Iterator
7
+
8
+ from harness_common.artifact import Artifact
9
+ from harness_common.execution import Execution
10
+ from harness_common.operation import OperationRun
11
+ from harness_common.outcome import Outcome
12
+
13
+
14
+ @dataclass(kw_only=True)
15
+ class Experiment:
16
+ """A named, reproducible verification intent.
17
+
18
+ Domain harnesses add what varies and how it is executed. The shared concept owns only
19
+ the stable name used to group runs.
20
+ """
21
+
22
+ name: str
23
+
24
+
25
+ @dataclass
26
+ class ExperimentRun:
27
+ """One execution of an Experiment and the outputs it produced.
28
+
29
+ ``experiment`` remains the Experiment name on the persisted boundary so a run artifact
30
+ does not embed the complete, domain-specific experiment object.
31
+ """
32
+
33
+ run_id: str
34
+ experiment: str
35
+ created_at: str
36
+ executions: list[Execution] = field(default_factory=list, kw_only=True)
37
+ artifacts: list[Artifact] = field(default_factory=list, kw_only=True)
38
+
39
+ def operation_runs(self) -> Iterator[OperationRun]:
40
+ """Iterate all OperationRuns without duplicating the stored hierarchy."""
41
+ for execution in self.executions:
42
+ yield from execution.operation_runs
43
+
44
+ def outcomes(self) -> Iterator[Outcome]:
45
+ """Iterate raw Outcomes in execution order."""
46
+ for operation_run in self.operation_runs():
47
+ yield operation_run.outcome
48
+
49
+ def add_artifact(self, name: str, path: str) -> None:
50
+ """Register or replace one artifact by its logical name."""
51
+ artifact = Artifact(name=name, path=path)
52
+ for index, current in enumerate(self.artifacts):
53
+ if current.name == name:
54
+ self.artifacts[index] = artifact
55
+ return
56
+ self.artifacts.append(artifact)
57
+
58
+ def artifact_paths(self) -> dict[str, str]:
59
+ """Project typed artifacts to the cross-harness verdict wire shape."""
60
+ return {artifact.name: artifact.path for artifact in self.artifacts}
@@ -0,0 +1,12 @@
1
+ """Stable identity of a source-code forge."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class Forge:
10
+ """A system that hosts source-code repositories."""
11
+
12
+ name: str
harness_common/host.py ADDED
@@ -0,0 +1,45 @@
1
+ """Host identity and access configuration; operations belong to toolbox."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class Host:
8
+ """An optional constituent of an Environment.
9
+
10
+ For a host environment this machine hosts the target. For Kubernetes it
11
+ provides cluster access, and kubeconfig paths refer to its filesystem.
12
+ SSH addresses use the caller's SSH configuration; credentials are not stored.
13
+ """
14
+
15
+ name: str
16
+ transport: str = "local"
17
+ address: str = ""
18
+
19
+ def validate(self) -> None:
20
+ if not self.name:
21
+ raise ValueError("host requires name")
22
+ if self.transport in {"", "local"}:
23
+ if self.address:
24
+ raise ValueError("local host cannot specify SSH address")
25
+ elif self.transport == "ssh":
26
+ if not self.address or self.address.startswith("-"):
27
+ raise ValueError("SSH host requires a non-option address")
28
+ else:
29
+ raise ValueError(f"unknown host transport {self.transport!r}")
30
+
31
+
32
+ def parse_host(data: dict) -> Host:
33
+ """Parse the shared Host wire representation."""
34
+ if not isinstance(data, dict):
35
+ raise ValueError("host must be a mapping")
36
+ unknown = set(data) - {"name", "transport", "address"}
37
+ if unknown:
38
+ raise ValueError(
39
+ f"host contains unknown field(s): {', '.join(sorted(unknown))}"
40
+ )
41
+ if any(not isinstance(value, str) for value in data.values()):
42
+ raise ValueError("host fields must be strings")
43
+ host = Host(**data) if "name" in data else Host(name="", **data)
44
+ host.validate()
45
+ return host
@@ -0,0 +1,11 @@
1
+ """common.llm — OpenAI-compatible LLM client, harness-neutral shared infrastructure.
2
+
3
+ A model call (endpoint / auth / timeout / retry / provenance) is not specific to any one
4
+ harness: eval's LLM-as-judge metrics use it today, and e2e's casegen ``LLMCompiler`` will use
5
+ it tomorrow. So it lives in ``common`` (like ``case`` / ``verdict`` / ``facets``) — the 3 SDKs
6
+ compose ``LLMClient`` without importing each other.
7
+ """
8
+
9
+ from harness_common.llm.client import ChatResult as ChatResult
10
+ from harness_common.llm.client import LLMClient as LLMClient
11
+ from harness_common.llm.client import LLMConfig as LLMConfig
@@ -0,0 +1,165 @@
1
+ """OpenAI-compatible LLM client with retry — shared by LLM-backed components.
2
+
3
+ Owns the "call a model" concern (endpoint / auth / timeout / retry / provenance)
4
+ so LLM-as-judge metrics and any future LLM-backed component (multi-aspect
5
+ judges, LLM dataset builders, ...) **compose** an ``LLMClient`` instead of
6
+ re-implementing HTTP + retry per metric.
7
+
8
+ Layering:
9
+ - ``LLMConfig`` — endpoint + key + model + timeout + retry knobs (``from_env``)
10
+ - ``ChatResult`` — assistant text + ``model`` provenance (which model answered)
11
+ - ``LLMClient`` — the call: OpenAI-compatible ``/chat/completions`` + retry,
12
+ with an optional shared ``asyncio.Semaphore`` to cap in-flight concurrency
13
+ (e.g. many judges × many samples hitting one endpoint → avoid 429s)
14
+
15
+ Robustness is split by layer: the client *raises* on persistent failure;
16
+ callers (e.g. ``BaseLLMJudge.score``) decide how to degrade (judge → None).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import os
23
+ from dataclasses import dataclass
24
+
25
+ import httpx
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class LLMConfig:
30
+ """Endpoint + key + model for an OpenAI-compatible chat completion API.
31
+
32
+ Resolve from env via ``from_env``; pass empty values explicitly for tests.
33
+ The default env var names (``EVAL_JUDGE_BASE`` / ``_KEY`` / ``_MODEL``) keep
34
+ LLM secrets out of yaml; pass other names for non-judge uses.
35
+ """
36
+
37
+ base_url: str
38
+ api_key: str
39
+ model: str
40
+ timeout_s: float = 60.0
41
+ # 瞬时失败重试:并发调模型时偶发超时 / 429 / 5xx 不应让调用方直接失败。
42
+ # 总尝试 = max_retries + 1。
43
+ max_retries: int = 2
44
+ retry_backoff_s: float = 1.0 # 指数退避基数:第 i 次重试前 sleep backoff * 2**i
45
+
46
+ @classmethod
47
+ def from_env(
48
+ cls,
49
+ *,
50
+ base_env: str = "EVAL_JUDGE_BASE",
51
+ key_env: str = "EVAL_JUDGE_KEY",
52
+ model_env: str = "EVAL_JUDGE_MODEL",
53
+ timeout_s: float = 60.0,
54
+ max_retries: int = 2,
55
+ retry_backoff_s: float = 1.0,
56
+ ) -> LLMConfig:
57
+ return cls(
58
+ base_url=os.environ.get(base_env, "").rstrip("/"),
59
+ api_key=os.environ.get(key_env, ""),
60
+ model=os.environ.get(model_env, ""),
61
+ timeout_s=timeout_s,
62
+ max_retries=max_retries,
63
+ retry_backoff_s=retry_backoff_s,
64
+ )
65
+
66
+ def ready(self) -> bool:
67
+ return bool(self.base_url and self.api_key and self.model)
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class ChatResult:
72
+ """One completion: the assistant text + which model produced it (provenance)."""
73
+
74
+ text: str
75
+ model: str
76
+
77
+
78
+ class LLMClient:
79
+ """Async OpenAI-compatible chat client with retry on transient failures.
80
+
81
+ Construct one and share it across metrics to reuse config and (optionally) a
82
+ concurrency cap. Retries timeouts / network errors / 429 / 5xx up to
83
+ ``config.max_retries`` times with exponential backoff; other 4xx are not
84
+ retried (they won't fix themselves). On exhaustion the last error raises.
85
+ """
86
+
87
+ def __init__(
88
+ self, config: LLMConfig, *, semaphore: asyncio.Semaphore | None = None
89
+ ):
90
+ self.config = config
91
+ # 可选全局并发闸:多个 judge × 多 sample 并发判分时,限制对单个端点的在途请求数。
92
+ self._semaphore = semaphore
93
+
94
+ @classmethod
95
+ def from_env(
96
+ cls, *, semaphore: asyncio.Semaphore | None = None, **env_kwargs
97
+ ) -> LLMClient:
98
+ return cls(LLMConfig.from_env(**env_kwargs), semaphore=semaphore)
99
+
100
+ def ready(self) -> bool:
101
+ return self.config.ready()
102
+
103
+ async def complete(self, system: str, user: str) -> ChatResult:
104
+ """Convenience: a single system+user turn."""
105
+ return await self.chat(
106
+ [
107
+ {"role": "system", "content": system},
108
+ {"role": "user", "content": user},
109
+ ]
110
+ )
111
+
112
+ async def chat(self, messages: list[dict]) -> ChatResult:
113
+ """Call ``/chat/completions`` with retry. Raises on persistent failure."""
114
+ attempts = max(1, self.config.max_retries + 1)
115
+ async with httpx.AsyncClient(timeout=self.config.timeout_s) as client:
116
+ for i in range(attempts):
117
+ try:
118
+ return await self._post(client, messages)
119
+ except Exception as exc:
120
+ if i + 1 < attempts and _is_retryable(exc):
121
+ await asyncio.sleep(self.config.retry_backoff_s * (2**i))
122
+ continue
123
+ raise
124
+ raise RuntimeError("unreachable") # pragma: no cover
125
+
126
+ async def _post(
127
+ self, client: httpx.AsyncClient, messages: list[dict]
128
+ ) -> ChatResult:
129
+ """A single attempt. The optional semaphore caps concurrent in-flight
130
+ calls (released during backoff so retries don't hog slots)."""
131
+ if self._semaphore is not None:
132
+ async with self._semaphore:
133
+ return await self._post_once(client, messages)
134
+ return await self._post_once(client, messages)
135
+
136
+ async def _post_once(
137
+ self, client: httpx.AsyncClient, messages: list[dict]
138
+ ) -> ChatResult:
139
+ resp = await client.post(
140
+ f"{self.config.base_url}/chat/completions",
141
+ headers={
142
+ "Authorization": f"Bearer {self.config.api_key}",
143
+ "Content-Type": "application/json",
144
+ },
145
+ json={
146
+ "model": self.config.model,
147
+ "messages": messages,
148
+ "temperature": 0,
149
+ },
150
+ )
151
+ resp.raise_for_status()
152
+ data = resp.json()
153
+ text = data["choices"][0]["message"]["content"]
154
+ return ChatResult(text=text, model=data.get("model") or self.config.model)
155
+
156
+
157
+ def _is_retryable(exc: Exception) -> bool:
158
+ """Transient errors worth retrying: timeouts, network/transport errors,
159
+ 429, and 5xx. Other 4xx (bad request / auth) won't fix on retry."""
160
+ if isinstance(exc, httpx.TimeoutException | httpx.TransportError):
161
+ return True
162
+ if isinstance(exc, httpx.HTTPStatusError):
163
+ code = exc.response.status_code
164
+ return code == 429 or code >= 500
165
+ return False