patchahead 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.
- patchahead/__init__.py +8 -0
- patchahead/analysis/__init__.py +52 -0
- patchahead/analysis/edits.py +143 -0
- patchahead/analysis/index.py +203 -0
- patchahead/analysis/python_ast.py +457 -0
- patchahead/apidiff/__init__.py +23 -0
- patchahead/apidiff/compare.py +366 -0
- patchahead/apidiff/download.py +95 -0
- patchahead/apidiff/surface.py +337 -0
- patchahead/ci.py +301 -0
- patchahead/cli.py +627 -0
- patchahead/config.py +284 -0
- patchahead/demo/__init__.py +256 -0
- patchahead/demo/fixtures/changes/field-rename.md +14 -0
- patchahead/demo/fixtures/changes/invoice-field-rename.md +21 -0
- patchahead/demo/fixtures/changes/kwarg-rename.md +14 -0
- patchahead/demo/fixtures/changes/method-rename.md +12 -0
- patchahead/demo/fixtures/changes/pagination-cursor.json +24 -0
- patchahead/demo/fixtures/changes/pagination-cursor.md +20 -0
- patchahead/demo/fixtures/changes/sdk-v2.md +31 -0
- patchahead/demo/fixtures/orders-service/README.md +51 -0
- patchahead/demo/fixtures/orders-service/app/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/app/client.py +15 -0
- patchahead/demo/fixtures/orders-service/app/models.py +10 -0
- patchahead/demo/fixtures/orders-service/app/order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/app/order_sync.py +21 -0
- patchahead/demo/fixtures/orders-service/conftest.py +6 -0
- patchahead/demo/fixtures/orders-service/pyproject.toml +16 -0
- patchahead/demo/fixtures/orders-service/tests/test_client.py +14 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_report.py +24 -0
- patchahead/demo/fixtures/orders-service/tests/test_order_sync.py +11 -0
- patchahead/demo/fixtures/orders-service/upstream/__init__.py +0 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v1.py +34 -0
- patchahead/demo/fixtures/orders-service/upstream/api_v2.py +56 -0
- patchahead/demo/serve.py +189 -0
- patchahead/domain/__init__.py +67 -0
- patchahead/domain/change.py +269 -0
- patchahead/domain/completeness.py +91 -0
- patchahead/domain/impact.py +248 -0
- patchahead/domain/patch.py +81 -0
- patchahead/domain/plan.py +170 -0
- patchahead/domain/result.py +210 -0
- patchahead/domain/validation.py +200 -0
- patchahead/engine.py +609 -0
- patchahead/handlers/__init__.py +35 -0
- patchahead/handlers/base.py +211 -0
- patchahead/handlers/field_rename.py +425 -0
- patchahead/handlers/kwarg_rename.py +201 -0
- patchahead/handlers/method_rename.py +608 -0
- patchahead/handlers/pagination.py +582 -0
- patchahead/ingest/__init__.py +32 -0
- patchahead/ingest/base.py +102 -0
- patchahead/ingest/markdown.py +1138 -0
- patchahead/ingest/structured.py +218 -0
- patchahead/llm/__init__.py +28 -0
- patchahead/llm/client.py +152 -0
- patchahead/llm/proposer.py +620 -0
- patchahead/observability.py +223 -0
- patchahead/reporting.py +451 -0
- patchahead/testing/__init__.py +22 -0
- patchahead/testing/discovery.py +113 -0
- patchahead/testing/runner.py +138 -0
- patchahead/validation/__init__.py +5 -0
- patchahead/validation/completeness.py +265 -0
- patchahead/validation/engine.py +531 -0
- patchahead/web/__init__.py +13 -0
- patchahead/web/server.py +279 -0
- patchahead/web/static/index.html +650 -0
- patchahead/workspace.py +382 -0
- patchahead-0.3.0.dist-info/METADATA +368 -0
- patchahead-0.3.0.dist-info/RECORD +75 -0
- patchahead-0.3.0.dist-info/WHEEL +5 -0
- patchahead-0.3.0.dist-info/entry_points.txt +2 -0
- patchahead-0.3.0.dist-info/licenses/LICENSE +21 -0
- patchahead-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Parsing structured change descriptions (JSON and YAML).
|
|
2
|
+
|
|
3
|
+
The escape hatch for when release-note prose is not good enough: a user (or a
|
|
4
|
+
future spec-diff tool) states the change explicitly and PatchAhead does no
|
|
5
|
+
guessing. Structured input therefore defaults to ``Confidence.HIGH`` -- the
|
|
6
|
+
uncertainty in the Markdown path is entirely about *reading English*, and that
|
|
7
|
+
uncertainty is absent here.
|
|
8
|
+
|
|
9
|
+
Schema, as a single change or a list of them under ``changes``::
|
|
10
|
+
|
|
11
|
+
{
|
|
12
|
+
"title": "Order field renamed", # required
|
|
13
|
+
"kind": "field_rename", # required
|
|
14
|
+
"old_behavior": "...",
|
|
15
|
+
"new_behavior": "...",
|
|
16
|
+
"migration_hint": "...",
|
|
17
|
+
"severity": "high" | "medium" | "low",
|
|
18
|
+
"confidence": "high" | "medium" | "low",
|
|
19
|
+
"target": {"symbol": "total", "replacement": "amount", "owner": "order",
|
|
20
|
+
"owner_explicit": true},
|
|
21
|
+
"pagination": {"page_param": "page", "total_pages_key": "total_pages", ...},
|
|
22
|
+
"evidence": ["quoted line", {"quote": "...", "line": 12, "note": "..."}]
|
|
23
|
+
}
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import logging
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from patchahead.domain.change import (
|
|
33
|
+
BreakingChange,
|
|
34
|
+
ChangeKind,
|
|
35
|
+
Confidence,
|
|
36
|
+
Evidence,
|
|
37
|
+
PaginationContract,
|
|
38
|
+
Severity,
|
|
39
|
+
SymbolTarget,
|
|
40
|
+
)
|
|
41
|
+
from patchahead.ingest.base import ChangeDocument, ChangeParser, IngestError, register
|
|
42
|
+
|
|
43
|
+
log = logging.getLogger(__name__)
|
|
44
|
+
|
|
45
|
+
_VALID_KINDS = ", ".join(sorted(k.value for k in ChangeKind))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _parse_yaml(text: str, path: str) -> Any:
|
|
49
|
+
try:
|
|
50
|
+
import yaml
|
|
51
|
+
except ImportError: # pragma: no cover - depends on the environment
|
|
52
|
+
raise IngestError(
|
|
53
|
+
f"cannot parse {path}: YAML support requires PyYAML "
|
|
54
|
+
"(pip install 'patchahead[yaml]'). JSON change documents work without it."
|
|
55
|
+
) from None
|
|
56
|
+
try:
|
|
57
|
+
return yaml.safe_load(text)
|
|
58
|
+
except Exception as exc:
|
|
59
|
+
raise IngestError(f"{path} is not valid YAML: {exc}") from exc
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _evidence_from(raw: Any) -> list[Evidence]:
|
|
63
|
+
if not isinstance(raw, list):
|
|
64
|
+
return []
|
|
65
|
+
evidence: list[Evidence] = []
|
|
66
|
+
for item in raw:
|
|
67
|
+
if isinstance(item, str):
|
|
68
|
+
evidence.append(Evidence(quote=item))
|
|
69
|
+
elif isinstance(item, dict):
|
|
70
|
+
line = item.get("line")
|
|
71
|
+
evidence.append(
|
|
72
|
+
Evidence(
|
|
73
|
+
quote=str(item.get("quote", "")),
|
|
74
|
+
line=int(line) if isinstance(line, int) else None,
|
|
75
|
+
note=str(item.get("note", "")),
|
|
76
|
+
)
|
|
77
|
+
)
|
|
78
|
+
return evidence
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def change_from_mapping(data: dict[str, Any], path: str, source: str) -> BreakingChange:
|
|
82
|
+
"""Build one :class:`BreakingChange` from a mapping, validating as we go."""
|
|
83
|
+
title = data.get("title")
|
|
84
|
+
if not isinstance(title, str) or not title.strip():
|
|
85
|
+
raise IngestError(f"{path}: each change needs a non-empty `title`")
|
|
86
|
+
|
|
87
|
+
raw_kind = data.get("kind") or data.get("change_type")
|
|
88
|
+
if not isinstance(raw_kind, str):
|
|
89
|
+
raise IngestError(f"{path}: change {title!r} needs a `kind`. One of: {_VALID_KINDS}")
|
|
90
|
+
try:
|
|
91
|
+
kind = ChangeKind(raw_kind.strip().lower())
|
|
92
|
+
except ValueError:
|
|
93
|
+
raise IngestError(
|
|
94
|
+
f"{path}: change {title!r} has unknown kind {raw_kind!r}. One of: {_VALID_KINDS}"
|
|
95
|
+
) from None
|
|
96
|
+
|
|
97
|
+
raw_target = data.get("target") or {}
|
|
98
|
+
if not isinstance(raw_target, dict):
|
|
99
|
+
raise IngestError(f"{path}: change {title!r} has a non-mapping `target`")
|
|
100
|
+
owner = str(raw_target.get("owner", "") or "")
|
|
101
|
+
explicit = raw_target.get("owner_explicit", True)
|
|
102
|
+
if not isinstance(explicit, bool):
|
|
103
|
+
raise IngestError(f"{path}: change {title!r} has a non-boolean `owner_explicit`")
|
|
104
|
+
target = SymbolTarget(
|
|
105
|
+
symbol=str(raw_target.get("symbol", "") or ""),
|
|
106
|
+
replacement=str(raw_target.get("replacement", "") or ""),
|
|
107
|
+
owner=owner,
|
|
108
|
+
# Someone typed this into a field named `owner`, which is an assertion
|
|
109
|
+
# by construction -- a receiver mismatch refuses rather than guessing.
|
|
110
|
+
# A generator that knows the class but not what callers name its
|
|
111
|
+
# instance (`api-diff`) writes `"owner_explicit": false` instead.
|
|
112
|
+
owner_is_explicit=bool(owner) and explicit,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
raw_pagination = data.get("pagination")
|
|
116
|
+
if raw_pagination is not None and not isinstance(raw_pagination, dict):
|
|
117
|
+
raise IngestError(f"{path}: change {title!r} has a non-mapping `pagination`")
|
|
118
|
+
|
|
119
|
+
return BreakingChange(
|
|
120
|
+
title=title.strip(),
|
|
121
|
+
kind=kind,
|
|
122
|
+
target=target,
|
|
123
|
+
old_behavior=str(data.get("old_behavior", "") or ""),
|
|
124
|
+
new_behavior=str(data.get("new_behavior", "") or ""),
|
|
125
|
+
migration_hint=str(data.get("migration_hint", "") or ""),
|
|
126
|
+
severity=Severity.parse(data.get("severity"), Severity.MEDIUM),
|
|
127
|
+
# Structured input is an explicit assertion by whoever wrote it; take it
|
|
128
|
+
# at face value unless it says otherwise.
|
|
129
|
+
confidence=Confidence.parse(data.get("confidence"), Confidence.HIGH),
|
|
130
|
+
evidence=_evidence_from(data.get("evidence")),
|
|
131
|
+
pagination=PaginationContract.from_dict(raw_pagination),
|
|
132
|
+
source=source,
|
|
133
|
+
classification_reason=str(
|
|
134
|
+
data.get("classification_reason")
|
|
135
|
+
or f"declared explicitly as `{kind.value}` in a structured change document"
|
|
136
|
+
),
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def change_to_mapping(change: BreakingChange) -> dict[str, Any]:
|
|
141
|
+
"""The inverse of :func:`change_from_mapping`: a change as a structured document entry."""
|
|
142
|
+
entry: dict[str, Any] = {
|
|
143
|
+
"title": change.title,
|
|
144
|
+
"kind": change.kind.value,
|
|
145
|
+
"severity": change.severity.value,
|
|
146
|
+
"confidence": change.confidence.value,
|
|
147
|
+
"classification_reason": change.classification_reason,
|
|
148
|
+
}
|
|
149
|
+
if change.target.symbol or change.target.owner:
|
|
150
|
+
entry["target"] = {
|
|
151
|
+
"symbol": change.target.symbol,
|
|
152
|
+
"replacement": change.target.replacement,
|
|
153
|
+
"owner": change.target.owner,
|
|
154
|
+
"owner_explicit": change.target.owner_is_explicit,
|
|
155
|
+
}
|
|
156
|
+
if change.kind is ChangeKind.PAGINATION_PAGE_TO_CURSOR:
|
|
157
|
+
entry["pagination"] = change.pagination.to_dict()
|
|
158
|
+
if change.evidence:
|
|
159
|
+
entry["evidence"] = [
|
|
160
|
+
{"quote": e.quote, "line": e.line, "note": e.note}
|
|
161
|
+
if e.line
|
|
162
|
+
else {"quote": e.quote, "note": e.note}
|
|
163
|
+
for e in change.evidence
|
|
164
|
+
]
|
|
165
|
+
return entry
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class StructuredChangeParser(ChangeParser):
|
|
169
|
+
"""Parses JSON and YAML change descriptions."""
|
|
170
|
+
|
|
171
|
+
name = "structured"
|
|
172
|
+
suffixes = (".json", ".yaml", ".yml")
|
|
173
|
+
|
|
174
|
+
def supports(self, document: ChangeDocument) -> bool:
|
|
175
|
+
return document.suffix in self.suffixes
|
|
176
|
+
|
|
177
|
+
def parse(self, document: ChangeDocument) -> list[BreakingChange]:
|
|
178
|
+
if document.suffix == ".json":
|
|
179
|
+
try:
|
|
180
|
+
data = json.loads(document.text)
|
|
181
|
+
except json.JSONDecodeError as exc:
|
|
182
|
+
raise IngestError(
|
|
183
|
+
f"{document.path} is not valid JSON: {exc.msg} "
|
|
184
|
+
f"(line {exc.lineno}, column {exc.colno})"
|
|
185
|
+
) from exc
|
|
186
|
+
else:
|
|
187
|
+
data = _parse_yaml(document.text, document.path)
|
|
188
|
+
|
|
189
|
+
if isinstance(data, dict) and "changes" in data:
|
|
190
|
+
entries = data["changes"]
|
|
191
|
+
elif isinstance(data, list):
|
|
192
|
+
entries = data
|
|
193
|
+
elif isinstance(data, dict):
|
|
194
|
+
entries = [data]
|
|
195
|
+
else:
|
|
196
|
+
raise IngestError(
|
|
197
|
+
f"{document.path}: expected an object, a list of objects, or an "
|
|
198
|
+
f"object with a `changes` list; got {type(data).__name__}"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if not isinstance(entries, list):
|
|
202
|
+
raise IngestError(f"{document.path}: `changes` must be a list")
|
|
203
|
+
if not entries:
|
|
204
|
+
raise IngestError(f"{document.path}: contains no changes")
|
|
205
|
+
|
|
206
|
+
changes = []
|
|
207
|
+
for entry in entries:
|
|
208
|
+
if not isinstance(entry, dict):
|
|
209
|
+
raise IngestError(
|
|
210
|
+
f"{document.path}: each change must be an object, got {type(entry).__name__}"
|
|
211
|
+
)
|
|
212
|
+
changes.append(change_from_mapping(entry, document.path, self.name))
|
|
213
|
+
|
|
214
|
+
log.debug("parsed %s: %d structured change(s)", document.path, len(changes))
|
|
215
|
+
return changes
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
register(StructuredChangeParser())
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Optional LLM assistance. PatchAhead has no runtime dependency on it.
|
|
2
|
+
|
|
3
|
+
The model is a proposal engine, never the authority: everything it returns is
|
|
4
|
+
structurally checked by :mod:`patchahead.llm.proposer` and then put through the
|
|
5
|
+
same validation gates as a deterministic patch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from patchahead.llm.client import (
|
|
9
|
+
DEFAULT_MODEL,
|
|
10
|
+
LLMClient,
|
|
11
|
+
LLMError,
|
|
12
|
+
LLMResponse,
|
|
13
|
+
LLMUnavailable,
|
|
14
|
+
available,
|
|
15
|
+
model_name,
|
|
16
|
+
)
|
|
17
|
+
from patchahead.llm.proposer import LLMProposer
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_MODEL",
|
|
21
|
+
"LLMClient",
|
|
22
|
+
"LLMError",
|
|
23
|
+
"LLMProposer",
|
|
24
|
+
"LLMResponse",
|
|
25
|
+
"LLMUnavailable",
|
|
26
|
+
"available",
|
|
27
|
+
"model_name",
|
|
28
|
+
]
|
patchahead/llm/client.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""A thin, honest wrapper over the Anthropic API.
|
|
2
|
+
|
|
3
|
+
Two things this does that the prototype did not:
|
|
4
|
+
|
|
5
|
+
**It distinguishes failure modes.** The prototype wrapped every call in
|
|
6
|
+
``except Exception: return None``, so a bad API key, a network outage, a
|
|
7
|
+
malformed response, and "the LLM is switched off" were indistinguishable
|
|
8
|
+
(``docs/assessment.md`` §2.6). Here each raises or returns a typed
|
|
9
|
+
:class:`LLMError` with a message a user can act on.
|
|
10
|
+
|
|
11
|
+
**It never sends more than it was given.** The caller assembles the prompt from
|
|
12
|
+
a named, bounded set of inputs. This module adds nothing, reads no files, and
|
|
13
|
+
logs no prompt content above debug level.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
log = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
#: Default model. Overridable with ``PATCHAHEAD_MODEL``.
|
|
25
|
+
DEFAULT_MODEL = "claude-opus-5"
|
|
26
|
+
DEFAULT_MAX_TOKENS = 8000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class LLMError(Exception):
|
|
30
|
+
"""A structured LLM failure. Carries a message meant for the user."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, message: str, *, retryable: bool = False) -> None:
|
|
33
|
+
super().__init__(message)
|
|
34
|
+
self.retryable = retryable
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class LLMUnavailable(LLMError):
|
|
38
|
+
"""The LLM path cannot run at all: no SDK, or no credentials."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class LLMResponse:
|
|
43
|
+
"""A completed model response."""
|
|
44
|
+
|
|
45
|
+
text: str
|
|
46
|
+
model: str
|
|
47
|
+
input_tokens: int = 0
|
|
48
|
+
output_tokens: int = 0
|
|
49
|
+
stop_reason: str = ""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def available() -> tuple[bool, str]:
|
|
53
|
+
"""Whether the LLM path can run, and why not if it cannot."""
|
|
54
|
+
try:
|
|
55
|
+
import anthropic # noqa: F401
|
|
56
|
+
except ImportError:
|
|
57
|
+
return False, ("the `anthropic` package is not installed (pip install 'patchahead[llm]')")
|
|
58
|
+
if not (os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("ANTHROPIC_AUTH_TOKEN")):
|
|
59
|
+
return False, "no ANTHROPIC_API_KEY (or ANTHROPIC_AUTH_TOKEN) is set"
|
|
60
|
+
return True, ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def model_name() -> str:
|
|
64
|
+
return os.environ.get("PATCHAHEAD_MODEL", DEFAULT_MODEL)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LLMClient:
|
|
68
|
+
"""Sends one request and returns the text, or raises :class:`LLMError`."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, model: str | None = None, max_tokens: int = DEFAULT_MAX_TOKENS) -> None:
|
|
71
|
+
self.model = model or model_name()
|
|
72
|
+
self.max_tokens = max_tokens
|
|
73
|
+
self._client = None
|
|
74
|
+
|
|
75
|
+
def _ensure_client(self):
|
|
76
|
+
if self._client is not None:
|
|
77
|
+
return self._client
|
|
78
|
+
ok, reason = available()
|
|
79
|
+
if not ok:
|
|
80
|
+
raise LLMUnavailable(reason)
|
|
81
|
+
import anthropic
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
self._client = anthropic.Anthropic()
|
|
85
|
+
except Exception as exc:
|
|
86
|
+
raise LLMUnavailable(f"could not construct the Anthropic client: {exc}") from exc
|
|
87
|
+
return self._client
|
|
88
|
+
|
|
89
|
+
def complete(self, system: str, user: str) -> LLMResponse:
|
|
90
|
+
"""Send one request. Raises :class:`LLMError` on any failure."""
|
|
91
|
+
# `_ensure_client` first: it turns a missing package or missing
|
|
92
|
+
# credentials into an LLMUnavailable the caller can report. Importing
|
|
93
|
+
# `anthropic` before that check would raise ModuleNotFoundError straight
|
|
94
|
+
# past every handler in the stack.
|
|
95
|
+
client = self._ensure_client()
|
|
96
|
+
import anthropic
|
|
97
|
+
|
|
98
|
+
log.debug("llm request: model=%s, %d chars of input", self.model, len(user))
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
message = client.messages.create(
|
|
102
|
+
model=self.model,
|
|
103
|
+
max_tokens=self.max_tokens,
|
|
104
|
+
system=system,
|
|
105
|
+
thinking={"type": "adaptive"},
|
|
106
|
+
messages=[{"role": "user", "content": user}],
|
|
107
|
+
)
|
|
108
|
+
except anthropic.AuthenticationError as exc:
|
|
109
|
+
raise LLMError(f"the Anthropic API rejected the credentials: {exc}") from exc
|
|
110
|
+
except anthropic.RateLimitError as exc:
|
|
111
|
+
raise LLMError(f"rate limited by the Anthropic API: {exc}", retryable=True) from exc
|
|
112
|
+
except anthropic.APIConnectionError as exc:
|
|
113
|
+
raise LLMError(f"could not reach the Anthropic API: {exc}", retryable=True) from exc
|
|
114
|
+
except anthropic.APIStatusError as exc:
|
|
115
|
+
raise LLMError(
|
|
116
|
+
f"the Anthropic API returned {exc.status_code}: {exc}",
|
|
117
|
+
retryable=exc.status_code >= 500,
|
|
118
|
+
) from exc
|
|
119
|
+
except Exception as exc: # unexpected SDK-level failure
|
|
120
|
+
raise LLMError(f"unexpected error calling the Anthropic API: {exc}") from exc
|
|
121
|
+
|
|
122
|
+
if getattr(message, "stop_reason", "") == "refusal":
|
|
123
|
+
details = getattr(message, "stop_details", None)
|
|
124
|
+
category = getattr(details, "category", "") if details else ""
|
|
125
|
+
raise LLMError(
|
|
126
|
+
"the model declined to answer" + (f" (category: {category})" if category else "")
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
text = "".join(
|
|
130
|
+
block.text for block in message.content if getattr(block, "type", "") == "text"
|
|
131
|
+
)
|
|
132
|
+
if not text.strip():
|
|
133
|
+
raise LLMError(
|
|
134
|
+
f"the model returned no text (stop_reason: "
|
|
135
|
+
f"{getattr(message, 'stop_reason', 'unknown')})"
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
usage = getattr(message, "usage", None)
|
|
139
|
+
response = LLMResponse(
|
|
140
|
+
text=text,
|
|
141
|
+
model=getattr(message, "model", self.model),
|
|
142
|
+
input_tokens=getattr(usage, "input_tokens", 0) if usage else 0,
|
|
143
|
+
output_tokens=getattr(usage, "output_tokens", 0) if usage else 0,
|
|
144
|
+
stop_reason=getattr(message, "stop_reason", "") or "",
|
|
145
|
+
)
|
|
146
|
+
log.debug(
|
|
147
|
+
"llm response: %d in / %d out tokens, stop_reason=%s",
|
|
148
|
+
response.input_tokens,
|
|
149
|
+
response.output_tokens,
|
|
150
|
+
response.stop_reason,
|
|
151
|
+
)
|
|
152
|
+
return response
|