adopt-agent 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.
@@ -0,0 +1,78 @@
1
+ """The single model seam: budget, tracing, idempotency, adapters.
2
+
3
+ Contracts §10.1 · AI spec §1-§8 · implementation spec §4.13. Implemented at S7.
4
+
5
+ **Every model call in either repository passes through `Runner.run`.** The
6
+ `no-provider-sdk` import contract enforces the other half -- that provider
7
+ clients live only under `adapters/` -- so the rule survives contributors who
8
+ never read the AI spec.
9
+
10
+ **Nothing here imports an adapter eagerly**, and that is load-bearing twice
11
+ over: `adopt_store` depends on this package for the runtime-annex port, so an
12
+ eager import would drag a provider client into the store and break
13
+ `no-provider-sdk` correctly; and offline mode must refuse *before* the adapter
14
+ module loads, which is what makes "no socket opened" provable rather than
15
+ argued.
16
+
17
+ Invariants carried from S0 and still true: provider clients are imported only in
18
+ `adapters/`, budget logic lives only in `budget.py`, traces record digests and
19
+ never payloads, and no model identifier is hard-coded anywhere -- including as a
20
+ default, and including in this package's only data file, `prices.json`, whose
21
+ model names are price-table keys and select nothing.
22
+ """
23
+
24
+ from adopt_agent.annex import AgentRunRecord, AnnexRecords
25
+ from adopt_agent.api import (
26
+ Adapter,
27
+ AdapterInfo,
28
+ AdapterKind,
29
+ AdapterResponse,
30
+ AgentRequest,
31
+ AgentResult,
32
+ AgentRunner,
33
+ AgentStatus,
34
+ Artifact,
35
+ Budget,
36
+ Cost,
37
+ ToolCall,
38
+ ToolSpec,
39
+ Trace,
40
+ TraceStep,
41
+ )
42
+ from adopt_agent.budget import BudgetVerdict, Meter
43
+ from adopt_agent.pricing import ModelPrice, cost_usd, price_for, stale_rows
44
+ from adopt_agent.runner import Runner
45
+ from adopt_agent.schema_check import SchemaViolation, UnsupportedSchema, validate_against_schema
46
+ from adopt_agent.skills import LoadedSkill, load_skill
47
+
48
+ __all__ = [
49
+ "Adapter",
50
+ "AdapterInfo",
51
+ "AdapterKind",
52
+ "AdapterResponse",
53
+ "AgentRequest",
54
+ "AgentResult",
55
+ "AgentRunRecord",
56
+ "AgentRunner",
57
+ "AgentStatus",
58
+ "AnnexRecords",
59
+ "Artifact",
60
+ "Budget",
61
+ "BudgetVerdict",
62
+ "Cost",
63
+ "LoadedSkill",
64
+ "Meter",
65
+ "ModelPrice",
66
+ "Runner",
67
+ "SchemaViolation",
68
+ "ToolCall",
69
+ "ToolSpec",
70
+ "Trace",
71
+ "TraceStep",
72
+ "UnsupportedSchema",
73
+ "cost_usd",
74
+ "load_skill",
75
+ "price_for",
76
+ "stale_rows",
77
+ "validate_against_schema",
78
+ ]
@@ -0,0 +1,29 @@
1
+ """The only package permitted to import a model-provider client SDK.
2
+
3
+ Adapters translate; they never enforce policy (AI spec §1, §2). Everything hard
4
+ -- budget metering, the single output-schema retry, idempotency, tracing -- is
5
+ the seam's, which is what keeps an adapter thin enough to be written twice.
6
+
7
+ **This module deliberately imports no adapter.** `base.REGISTRY` maps an id to a
8
+ module path and `build_adapter` imports it on demand, for two reasons that both
9
+ matter:
10
+
11
+ 1. **`adopt_store` depends on `adopt_agent`** -- it realizes the runtime-annex
12
+ port (contracts §12, CR-45). If importing `adopt_agent` pulled every adapter
13
+ in, `adopt_store` would transitively reach a provider client and
14
+ `no-provider-sdk` would break -- correctly, because it would then be true.
15
+ 2. **Offline must refuse before the import**, not merely before the request.
16
+ F13.7 says a hosted adapter raises "before any socket opens"; refusing
17
+ before the module loads is the stronger claim, and it is what lets S6's
18
+ blocked-socket harness prove it rather than argue it from reading code.
19
+
20
+ `no-provider-sdk` still sees every import edge statically -- the contract is
21
+ about *where* a provider client may be imported, and the answer is here.
22
+
23
+ **At S7 no adapter imports a vendor SDK at all** *(CR-46)*. The hosted adapters
24
+ speak HTTP over the standard library, because `03` §7.3 forbids copyleft
25
+ `in-binary` and both official SDKs drag `certifi` (MPL-2.0) into the wheel. The
26
+ contract stays declared and preventive: it fires the moment anyone adds one.
27
+ """
28
+
29
+ __all__: list[str] = []
@@ -0,0 +1,227 @@
1
+ """One HTTPS client for every hosted adapter, over the standard library.
2
+
3
+ **Why the standard library and not a vendor SDK.** Owner decision, 2026-08-06,
4
+ recorded as CR-46. `03` §7.3 makes `in-binary` **permissive only, no copyleft,
5
+ ever** -- and the transitive closure of both official SDKs includes `certifi`
6
+ (MPL-2.0) and `tqdm` (MPL-2.0 AND MIT). The licence gate refused them, correctly.
7
+ Rather than amend the policy for two convenience libraries, the adapters talk
8
+ HTTP directly: `urllib.request` over `ssl.create_default_context()` uses the
9
+ **operating system's** trust store, so there is no CA bundle to vendor and no
10
+ copyleft to carry.
11
+
12
+ That trade is cheap precisely because of how thin an adapter is here. AI spec §1
13
+ assigns budget metering, the output-schema retry, idempotency and tracing to the
14
+ seam; an adapter translates one request and one response and owns exactly one
15
+ thing of its own -- a bounded retry on a *transient* failure, inside
16
+ `AGENT_ADAPTER_TIMEOUT_S`, where the seam's meter cannot double-count it.
17
+
18
+ **Nothing here reads a credential from anywhere but the environment**, and no
19
+ credential is logged, traced or returned (`03` §3). The error raised on failure
20
+ carries a status code and never a response body, because a provider's error body
21
+ can echo the prompt back.
22
+ """
23
+
24
+ import json
25
+ import ssl
26
+ import urllib.error
27
+ import urllib.request
28
+ from collections.abc import Callable
29
+ from typing import Any, Final
30
+
31
+ from adopt_const import AGENT_ADAPTER_TIMEOUT_S
32
+ from adopt_obs import AdoptError, ErrorCode
33
+
34
+ __all__ = ["post_json"]
35
+
36
+ #: Statuses worth one retry: the provider said "not now", not "not ever".
37
+ #: 4xx other than 429 are the caller's fault and retrying them spends money to
38
+ #: receive the same refusal.
39
+ _RETRYABLE: Final[frozenset[int]] = frozenset({408, 425, 429, 500, 502, 503, 504})
40
+
41
+ #: One retry, and only for the statuses above. AI spec §1 puts transient retry
42
+ #: with the adapter and bounds it; the seam does not retry at all, because that
43
+ #: would double-count cost.
44
+ _MAX_ATTEMPTS: Final[int] = 2
45
+
46
+ _CONTEXT: Final[ssl.SSLContext] = ssl.create_default_context()
47
+
48
+ #: Hosts a plaintext POST is permitted to. A locally served model on loopback
49
+ #: has no network to intercept; anything else carries a prompt in the clear, and
50
+ #: AI spec §8 has no exception for convenience.
51
+ _LOOPBACK: Final[frozenset[str]] = frozenset({"localhost", "127.0.0.1", "::1", "[::1]"})
52
+
53
+
54
+ #: The only fields lifted out of a provider's error body. Enumerated identifiers
55
+ #: naming a parameter or a failure class -- never free text. `message` is
56
+ #: excluded on purpose: a content-policy refusal quotes what triggered it.
57
+ _ERROR_IDENTIFIERS: Final[tuple[str, ...]] = ("type", "code", "param")
58
+
59
+ #: A hard ceiling on each identifier, so a provider that returns something
60
+ #: unexpected in one of these fields cannot turn this into a body dump.
61
+ # const-sync: ok -- a display cap for an error identifier, not a tunable.
62
+ _IDENTIFIER_MAX_CHARS: Final[int] = 64
63
+
64
+ #: The status that means "this request shape is wrong", and the only one worth
65
+ #: renegotiating. A 401 or a 404 is not fixed by dropping a parameter.
66
+ # const-sync: ok -- an HTTP status code, not CLI_COLD_START_MS.
67
+ _BAD_REQUEST: Final[int] = 400
68
+
69
+ #: A ceiling on renegotiation, so a provider rejecting a different field each
70
+ #: time terminates instead of looping. Three is the number of parameters any
71
+ #: adapter here declares negotiable.
72
+ # const-sync: ok -- a loop bound for parameter negotiation, not a tunable.
73
+ _MAX_ADJUSTMENTS: Final[int] = 3
74
+
75
+
76
+ def _body(exc: urllib.error.HTTPError) -> str:
77
+ """The error body, read once and memoized.
78
+
79
+ `HTTPError` is a file object: the second reader gets an empty string. Both
80
+ the diagnostic string and the negotiation need it, so neither may own it.
81
+ """
82
+ cached = getattr(exc, "_adopt_body", None)
83
+ if cached is None:
84
+ try:
85
+ cached = exc.read().decode("utf-8", errors="replace")
86
+ except Exception: # an error path must not raise
87
+ cached = ""
88
+ exc._adopt_body = cached # type: ignore[attr-defined]
89
+ return str(cached)
90
+
91
+
92
+ def _rejected(exc: urllib.error.HTTPError) -> tuple[str, str]:
93
+ """`(code, param)` from a provider error body, or `("", "")`."""
94
+ try:
95
+ payload = json.loads(_body(exc))
96
+ except Exception: # diagnosis is best-effort: an error path must not raise
97
+ return "", ""
98
+ error = payload.get("error") if isinstance(payload, dict) else None
99
+ if not isinstance(error, dict):
100
+ return "", ""
101
+ return str(error.get("code") or "")[:_IDENTIFIER_MAX_CHARS], str(error.get("param") or "")[
102
+ :_IDENTIFIER_MAX_CHARS
103
+ ]
104
+
105
+
106
+ def _identifiers(exc: urllib.error.HTTPError) -> str:
107
+ """`(type=…, param=…)` from a provider error body, or `""`.
108
+
109
+ Reading the body cannot itself fail the request: a provider under load
110
+ returns HTML, and an error path that raises while explaining an error is
111
+ strictly worse than one that says less.
112
+ """
113
+ try:
114
+ payload = json.loads(_body(exc))
115
+ except Exception: # diagnosis is best-effort: an error path must not raise
116
+ return ""
117
+ error = payload.get("error") if isinstance(payload, dict) else None
118
+ if not isinstance(error, dict):
119
+ return ""
120
+ parts = [
121
+ f"{field}={str(error[field])[:_IDENTIFIER_MAX_CHARS]}"
122
+ for field in _ERROR_IDENTIFIERS
123
+ if error.get(field)
124
+ ]
125
+ return f" ({', '.join(parts)})" if parts else ""
126
+
127
+
128
+ def post_json(
129
+ url: str,
130
+ payload: dict[str, Any],
131
+ headers: dict[str, str],
132
+ *,
133
+ adapter_id: str,
134
+ negotiate: Callable[[dict[str, Any], str, str], bool] | None = None,
135
+ ) -> dict[str, Any]:
136
+ """POST JSON and return the decoded object, or raise `AGENT_PROVIDER_ERROR`.
137
+
138
+ The body is never included in the raised message. A provider's error payload
139
+ routinely echoes the request back, and AI spec §8.3 says prompt text is not
140
+ retrievable from our artifacts -- an exception message is one of ours.
141
+
142
+ **Three identifier fields are the exception, and they are an allowlist rather
143
+ than a relaxation** *(CR-52)*. `HTTP 400` alone is undiagnosable: it says a
144
+ request was malformed and not which part, so the first real-model run left
145
+ "the request shape is wrong somewhere" as the whole finding, and the privacy
146
+ rule that produced that message is not one to soften. `error.type`,
147
+ `error.code` and `error.param` are **enumerated identifiers naming a
148
+ parameter or a failure class** -- `unsupported_parameter`, `temperature` --
149
+ and cannot carry prompt text, because they are not free-text fields.
150
+ `error.message` is deliberately **excluded**: it is free text, and a
151
+ content-policy refusal quotes the content that triggered it.
152
+
153
+ **`negotiate` is how a caller answers a 400 that names a parameter**
154
+ *(CR-52)*. It is handed `(payload, code, param)`, may adjust the payload in
155
+ place, and returns whether it did; a `True` costs one more attempt with the
156
+ adjusted payload. **This is not a transient retry** and is counted
157
+ separately -- a provider that rejects a parameter will reject it forever, so
158
+ retrying unchanged is spending money to receive the same refusal, while
159
+ retrying *changed* is the only way to discover a shape the endpoint accepts.
160
+
161
+ Why discovery rather than a table keyed by model: `04` §2 forbids any
162
+ hard-coded model identifier, *including as a default*, so a table mapping
163
+ model prefixes to parameter sets is exactly the "house lab" that rule exists
164
+ to prevent -- and it would be wrong the day a vendor ships a variant. The
165
+ provider names the parameter it will not take; that is the authority.
166
+ """
167
+ probe = urllib.request.Request(url, method="POST") # noqa: S310 -- checked immediately
168
+ if probe.type != "https" and probe.host.split(":")[0] not in _LOOPBACK:
169
+ raise AdoptError(
170
+ ErrorCode.AGENT_PROVIDER_ERROR,
171
+ message=f"{adapter_id} refused a plaintext endpoint that is not loopback",
172
+ hint=(
173
+ "Plaintext is permitted only to localhost, where there is no network "
174
+ "to intercept. Anything else carries a prompt across a wire in the "
175
+ "clear, and AI spec §8 does not have an exception for convenience."
176
+ ),
177
+ )
178
+
179
+ last: str = "no attempt was made"
180
+ #: Each parameter may be adjusted at most once, so negotiation terminates
181
+ #: even against a provider that rejects a different field every time.
182
+ adjustments = 0
183
+ attempt = 0
184
+ while attempt < _MAX_ATTEMPTS:
185
+ request = urllib.request.Request( # noqa: S310 -- scheme checked above
186
+ url,
187
+ data=json.dumps(payload).encode("utf-8"),
188
+ headers={**headers, "content-type": "application/json"},
189
+ method="POST",
190
+ )
191
+ try:
192
+ with urllib.request.urlopen( # noqa: S310 -- scheme checked above
193
+ request, timeout=AGENT_ADAPTER_TIMEOUT_S, context=_CONTEXT
194
+ ) as response:
195
+ decoded: dict[str, Any] = json.loads(response.read().decode("utf-8"))
196
+ return decoded
197
+ except urllib.error.HTTPError as exc:
198
+ last = f"HTTP {exc.code}{_identifiers(exc)}"
199
+ if (
200
+ negotiate is not None
201
+ and exc.code == _BAD_REQUEST
202
+ and adjustments < _MAX_ADJUSTMENTS
203
+ ):
204
+ code, param = _rejected(exc)
205
+ if param and negotiate(payload, code, param):
206
+ adjustments += 1
207
+ continue # a changed request, not a repeat of a refused one
208
+ if exc.code not in _RETRYABLE or attempt == _MAX_ATTEMPTS - 1:
209
+ break
210
+ except (urllib.error.URLError, TimeoutError) as exc:
211
+ last = f"{type(exc).__name__}"
212
+ if attempt == _MAX_ATTEMPTS - 1:
213
+ break
214
+ except json.JSONDecodeError:
215
+ last = "the provider returned a body that is not JSON"
216
+ break
217
+ attempt += 1
218
+
219
+ raise AdoptError(
220
+ ErrorCode.AGENT_PROVIDER_ERROR,
221
+ message=f"{adapter_id} failed after {_MAX_ATTEMPTS} attempt(s): {last}",
222
+ hint=(
223
+ "The seam does not retry beyond the adapter's own bounded attempts, and "
224
+ "it does not fall back to another adapter -- a silent substitution changes "
225
+ "cost, behaviour and data residency without the operator knowing."
226
+ ),
227
+ )
@@ -0,0 +1,183 @@
1
+ """The hosted Anthropic adapter. AI spec §2, kind `hosted`, offline **denied**.
2
+
3
+ Its own module rather than a flag on the OpenAI-compatible one, because the
4
+ Messages API differs where it matters: `system` is a top-level parameter rather
5
+ than a message, `max_tokens` is **required**, tool results come back as content
6
+ blocks rather than as a separate field, and usage is reported under different
7
+ keys. Folding two wire shapes into one class with branches is how an adapter
8
+ stops being a translation and starts being a place bugs hide.
9
+
10
+ **No model identifier appears in this file**, including as a default
11
+ (AI spec §2). **No credential is logged, traced or returned** (`03` §3).
12
+ """
13
+
14
+ import hashlib
15
+ import json
16
+ import os
17
+ from collections.abc import Mapping
18
+ from typing import Any, Final
19
+
20
+ from adopt_agent.adapters._wire import post_json
21
+ from adopt_agent.api import AdapterResponse, ToolCall, ToolSpec
22
+ from adopt_obs import AdoptError, ErrorCode
23
+
24
+ __all__ = ["AnthropicAdapter", "build"]
25
+
26
+ _URL: Final[str] = "https://api.anthropic.com/v1/messages"
27
+ _KEY_ENV: Final[str] = "ANTHROPIC_API_KEY"
28
+ _VERSION: Final[str] = "2023-06-01"
29
+ # const-sync: ok -- a determinism requirement from AI spec §2, not a tunable.
30
+ _TEMPERATURE: Final[float] = 0.0
31
+ #: The Messages API requires `max_tokens`. When the caller's budget sets no
32
+ #: token cap there is still a number to send, so the request is bounded rather
33
+ #: than open-ended -- an unbounded generation against a per-token price is the
34
+ #: failure the budget exists to prevent.
35
+ _FALLBACK_MAX_TOKENS: Final[int] = 4_096
36
+
37
+
38
+ class AnthropicAdapter:
39
+ """Realizes `api.Adapter` structurally over the Messages API."""
40
+
41
+ id: str = "anthropic"
42
+ kind: str = "hosted"
43
+
44
+ def __init__(self, *, model: str, api_key: str | None) -> None:
45
+ self._model = model
46
+ #: The content blocks of the previous assistant turn, kept so a
47
+ #: `tool_result` has the `tool_use` it answers. Per-run state: the
48
+ #: runner builds one adapter per `run()`, so this never spans runs.
49
+ self._previous_assistant: list[dict[str, object]] | None = None
50
+ self._api_key = api_key
51
+
52
+ def model(self) -> str:
53
+ return self._model
54
+
55
+ def params_hash(self) -> str:
56
+ """Excludes the credential: a hash of a secret is a lookup away from it."""
57
+ material = f"{self.id}|{_URL}|{self._model}|{_TEMPERATURE}|{_VERSION}"
58
+ return hashlib.sha256(material.encode("utf-8")).hexdigest()
59
+
60
+ def complete(
61
+ self,
62
+ *,
63
+ system: str,
64
+ user: str,
65
+ tools: list[ToolSpec],
66
+ tool_results: list[Mapping[str, Any]],
67
+ max_tokens: int | None,
68
+ ) -> AdapterResponse:
69
+ # A `tool_result` must answer a `tool_use` the API has already seen, in
70
+ # an assistant turn of the same conversation. Sending the result on its
71
+ # own -- which this adapter did until the first real-model run -- is a
72
+ # 400 every time, and it is why conformance cases 4 and 6 reported
73
+ # `status='error'` after the tool had demonstrably been invoked.
74
+ #
75
+ # The seam's signature does not carry conversation history and does not
76
+ # need to: `build_adapter` is called once per `run()`, so the adapter
77
+ # instance spans every turn of that run and can remember the assistant
78
+ # turn it just received. Keeping it here rather than widening `complete`
79
+ # is deliberate -- the history is a *provider wire* concern, and `02`
80
+ # §10.1's Protocol stays exactly as declared.
81
+ messages: list[dict[str, Any]] = [
82
+ {"role": "user", "content": [{"type": "text", "text": user}]}
83
+ ]
84
+ if tool_results and self._previous_assistant is not None:
85
+ messages.append({"role": "assistant", "content": self._previous_assistant})
86
+ messages.append(
87
+ {
88
+ "role": "user",
89
+ "content": [
90
+ {
91
+ "type": "tool_result",
92
+ "tool_use_id": str(result["id"]),
93
+ # `json.dumps`, not `str`: the payload is a dict, and
94
+ # `str` renders Python repr with single quotes, which
95
+ # is not JSON and is not what the model was shown.
96
+ "content": _render(result["content"]),
97
+ }
98
+ for result in tool_results
99
+ ],
100
+ }
101
+ )
102
+
103
+ payload: dict[str, Any] = {
104
+ "model": self._model,
105
+ "system": system,
106
+ "messages": messages,
107
+ "max_tokens": max_tokens or _FALLBACK_MAX_TOKENS,
108
+ "temperature": _TEMPERATURE,
109
+ }
110
+ if tools:
111
+ payload["tools"] = [
112
+ {
113
+ "name": tool.name,
114
+ "description": tool.description,
115
+ "input_schema": tool.input_schema,
116
+ }
117
+ for tool in tools
118
+ ]
119
+
120
+ headers = {"anthropic-version": _VERSION}
121
+ if self._api_key:
122
+ headers["x-api-key"] = self._api_key
123
+ body = post_json(_URL, payload, headers, adapter_id=self.id)
124
+ blocks = body.get("content")
125
+ # Kept verbatim rather than rebuilt from the normalized response: a
126
+ # `tool_use` block echoed back with its `id` and `input` reconstructed by
127
+ # us is a different block, and the API matches on what it sent.
128
+ self._previous_assistant = blocks if isinstance(blocks, list) else None
129
+ return _to_response(body)
130
+
131
+
132
+ def _render(content: object) -> str:
133
+ """A tool payload as JSON text, never as a Python repr."""
134
+ return content if isinstance(content, str) else json.dumps(content, sort_keys=True)
135
+
136
+
137
+ def _to_response(body: Mapping[str, Any]) -> AdapterResponse:
138
+ """Normalize one turn, refusing a shape this adapter cannot read.
139
+
140
+ Refusing rather than defaulting to empty text matters: an unreadable
141
+ response silently read as "no text, no tool calls" ends the seam's loop and
142
+ returns `ok`, which is a wrong answer dressed as a successful run.
143
+ """
144
+ blocks = body.get("content")
145
+ if not isinstance(blocks, list):
146
+ raise AdoptError(
147
+ ErrorCode.AGENT_PROVIDER_ERROR,
148
+ message="anthropic returned no content blocks",
149
+ hint="The endpoint answered with a shape this adapter cannot read.",
150
+ )
151
+
152
+ texts: list[str] = []
153
+ calls: list[ToolCall] = []
154
+ for block in blocks:
155
+ if block.get("type") == "text":
156
+ texts.append(str(block.get("text") or ""))
157
+ elif block.get("type") == "tool_use":
158
+ arguments = block.get("input")
159
+ calls.append(
160
+ ToolCall(
161
+ id=str(block.get("id") or "call"),
162
+ name=str(block.get("name") or "unknown"),
163
+ arguments=arguments if isinstance(arguments, dict) else {},
164
+ )
165
+ )
166
+
167
+ usage = body.get("usage") or {}
168
+ return AdapterResponse(
169
+ text="".join(texts),
170
+ tool_calls=calls,
171
+ input_tokens=int(usage.get("input_tokens") or 0),
172
+ output_tokens=int(usage.get("output_tokens") or 0),
173
+ )
174
+
175
+
176
+ def build(*, model: str | None, endpoint: str | None) -> AnthropicAdapter:
177
+ if not model:
178
+ raise AdoptError(
179
+ ErrorCode.AGENT_ADAPTER_UNKNOWN,
180
+ message="ADOPT_MODEL is unset and this adapter has no default model",
181
+ hint="Model selection is configuration, never code (AI spec §2).",
182
+ )
183
+ return AnthropicAdapter(model=model, api_key=os.environ.get(_KEY_ENV))
@@ -0,0 +1,140 @@
1
+ """The adapter registry and the offline gate. AI spec §2.
2
+
3
+ **The offline gate runs before the import, not before the request.** F13.7 says a
4
+ hosted adapter under offline mode raises "before any socket opens", and the
5
+ strongest available reading is stronger than that: this module refuses *before
6
+ the adapter module is even imported*, so no provider code loads, no client is
7
+ constructed and no connection pool exists. That also keeps `adopt` cold-start
8
+ inside `CLI_COLD_START_MS` -- nothing provider-shaped is imported by `adopt
9
+ version`.
10
+
11
+ **Adapters are resolved by name through a lazy import.** The registry maps an id
12
+ to a module path and `build_adapter` imports it on demand. `no-provider-sdk`
13
+ still sees every import edge statically, so the contract loses nothing: the
14
+ rule is about *where* a provider client may be imported, and every one of them
15
+ is under `adopt_agent.adapters`.
16
+
17
+ **There is no fallback.** AI spec §2: if the configured adapter is unavailable
18
+ the seam raises. A silent substitution changes cost, behaviour and data
19
+ residency without the operator knowing, and in a client environment that is a
20
+ security-review finding rather than a convenience.
21
+
22
+ **No model identifier appears anywhere in this package outside `prices.json`.**
23
+ `ADOPT_MODEL` names the model and an adapter fails fast with
24
+ `AGENT_ADAPTER_UNKNOWN` when it is absent. A hard-coded default is how a tool
25
+ aimed at every lab acquires a house lab.
26
+ """
27
+
28
+ import importlib
29
+ from typing import Final, Protocol
30
+
31
+ from adopt_agent.api import Adapter, AdapterInfo, AdapterKind
32
+ from adopt_obs import AdoptError, ErrorCode
33
+
34
+ __all__ = ["REGISTRY", "AdapterFactory", "build_adapter", "describe_adapters"]
35
+
36
+
37
+ class AdapterFactory(Protocol):
38
+ """What every adapter module exposes as `build`."""
39
+
40
+ def __call__(self, *, model: str | None, endpoint: str | None) -> Adapter: ...
41
+
42
+
43
+ class _Entry:
44
+ """One registry row: the kind, and where the code lives."""
45
+
46
+ def __init__(self, kind: AdapterKind, module: str, *, needs_endpoint: bool = False) -> None:
47
+ self.kind = kind
48
+ self.module = module
49
+ self.needs_endpoint = needs_endpoint
50
+
51
+
52
+ #: AI spec §2's registry, verbatim in its ids and kinds.
53
+ #:
54
+ #: `fake_recorded` is `test`, **not** `local`. That typing is load-bearing: the
55
+ #: `conformance-matrix` gate requires at least one *local* adapter green, and if
56
+ #: the recorded fake counted as local then a pipeline exercising no real model at
57
+ #: all would satisfy the gate whose entire purpose is to prove one was exercised.
58
+ REGISTRY: Final[dict[str, _Entry]] = {
59
+ "anthropic": _Entry("hosted", "adopt_agent.adapters.anthropic"),
60
+ "openai": _Entry("hosted", "adopt_agent.adapters.openai"),
61
+ "local_openai": _Entry("local", "adopt_agent.adapters.local_openai", needs_endpoint=True),
62
+ "fake_recorded": _Entry("test", "adopt_agent.adapters.fake_recorded"),
63
+ }
64
+
65
+
66
+ def _unknown(adapter_id: str) -> AdoptError:
67
+ return AdoptError(
68
+ ErrorCode.AGENT_ADAPTER_UNKNOWN,
69
+ message=f"no adapter is registered as {adapter_id!r}",
70
+ hint=f"Registered adapters: {', '.join(sorted(REGISTRY))}.",
71
+ )
72
+
73
+
74
+ def _offline_denied(adapter_id: str) -> AdoptError:
75
+ return AdoptError(
76
+ ErrorCode.AGENT_OFFLINE_ADAPTER_DENIED,
77
+ message=f"{adapter_id!r} is a hosted adapter and this process is offline",
78
+ hint=(
79
+ "Offline is the default (contracts §1.7). Pass --allow-network, or use "
80
+ "the local adapter, which is always available offline. Nothing was sent: "
81
+ "the refusal happens before the adapter module is imported."
82
+ ),
83
+ )
84
+
85
+
86
+ def _unavailable_reason(
87
+ entry: _Entry, *, offline: bool, model: str | None, endpoint: str | None
88
+ ) -> str | None:
89
+ """Why this adapter cannot be used right now, or `None` if it can.
90
+
91
+ Three different causes with three different fixes -- an offline denial, a
92
+ missing endpoint and an unset model -- so `AdapterInfo.reason` says which.
93
+ Reporting a bare `available: false` would leave an operator guessing.
94
+ """
95
+ if offline and entry.kind == "hosted":
96
+ return "hosted adapter denied under offline mode (ADOPT_OFFLINE)"
97
+ if entry.needs_endpoint and not endpoint:
98
+ return "ADOPT_ADAPTER_ENDPOINT is not set"
99
+ if entry.kind != "test" and not model:
100
+ return "ADOPT_MODEL is not set; the seam never picks a model for you"
101
+ return None
102
+
103
+
104
+ def describe_adapters(
105
+ *, offline: bool, model: str | None = None, endpoint: str | None = None
106
+ ) -> list[AdapterInfo]:
107
+ """Contracts §14's `adapters[{id, kind, available, reason}]`, unshaped.
108
+
109
+ Reports every registered adapter rather than only the usable ones: an
110
+ operator asking "why can I not use Anthropic" needs the row that says so.
111
+ """
112
+ infos: list[AdapterInfo] = []
113
+ for adapter_id in sorted(REGISTRY):
114
+ entry = REGISTRY[adapter_id]
115
+ reason = _unavailable_reason(entry, offline=offline, model=model, endpoint=endpoint)
116
+ infos.append(
117
+ AdapterInfo(id=adapter_id, kind=entry.kind, available=reason is None, reason=reason)
118
+ )
119
+ return infos
120
+
121
+
122
+ def build_adapter(
123
+ adapter_id: str, *, offline: bool, model: str | None = None, endpoint: str | None = None
124
+ ) -> Adapter:
125
+ """Resolve and construct one adapter, or raise.
126
+
127
+ The offline check is the **first** thing that happens after the id is
128
+ recognised, and it happens before `import_module`. That ordering is what
129
+ makes "no socket opened" provable by the blocked-socket harness rather than
130
+ argued from reading the adapter's code.
131
+ """
132
+ entry = REGISTRY.get(adapter_id)
133
+ if entry is None:
134
+ raise _unknown(adapter_id)
135
+ if offline and entry.kind == "hosted":
136
+ raise _offline_denied(adapter_id)
137
+
138
+ module = importlib.import_module(entry.module)
139
+ factory: AdapterFactory = module.build
140
+ return factory(model=model, endpoint=endpoint)