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.
- adopt_agent/__init__.py +78 -0
- adopt_agent/adapters/__init__.py +29 -0
- adopt_agent/adapters/_wire.py +227 -0
- adopt_agent/adapters/anthropic.py +183 -0
- adopt_agent/adapters/base.py +140 -0
- adopt_agent/adapters/fake_recorded.py +98 -0
- adopt_agent/adapters/local_openai.py +53 -0
- adopt_agent/adapters/openai.py +40 -0
- adopt_agent/adapters/openai_compatible.py +232 -0
- adopt_agent/annex.py +100 -0
- adopt_agent/api.py +346 -0
- adopt_agent/budget.py +184 -0
- adopt_agent/prices.json +45 -0
- adopt_agent/pricing.py +122 -0
- adopt_agent/py.typed +0 -0
- adopt_agent/runner.py +482 -0
- adopt_agent/schema_check.py +211 -0
- adopt_agent/skills.py +295 -0
- adopt_agent-0.3.0.dist-info/METADATA +16 -0
- adopt_agent-0.3.0.dist-info/RECORD +23 -0
- adopt_agent-0.3.0.dist-info/WHEEL +4 -0
- adopt_agent-0.3.0.dist-info/licenses/LICENSE +201 -0
- adopt_agent-0.3.0.dist-info/licenses/NOTICE +39 -0
adopt_agent/__init__.py
ADDED
|
@@ -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)
|