crupier 0.1.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.
crupier/__init__.py ADDED
@@ -0,0 +1,178 @@
1
+ """Crupier public API."""
2
+
3
+ from .client import Crupier
4
+ from .autopatch import install
5
+ from .errors import (
6
+ CrupierBudgetExceededError,
7
+ CrupierConfigError,
8
+ CrupierError,
9
+ CrupierModelUnsupportedError,
10
+ CrupierPolicyError,
11
+ CrupierProviderAuthError,
12
+ CrupierProviderRateLimitError,
13
+ CrupierProviderUnavailableError,
14
+ CrupierRouteValidationError,
15
+ CrupierStructuredOutputError,
16
+ CrupierToolApprovalRequired,
17
+ CrupierUpdateRequiresConfirmation,
18
+ )
19
+ from .evals import (
20
+ CompareDatasetCaseResult,
21
+ CompareDatasetModelScore,
22
+ CompareDatasetReport,
23
+ CompareHistoryReport,
24
+ CompareRunReport,
25
+ CompareVariant,
26
+ CompareVariantResult,
27
+ )
28
+ from .feedback import (
29
+ HumanDecisionImportResult,
30
+ HumanFeedbackRecord,
31
+ HumanFeedbackStore,
32
+ HumanReviewItem,
33
+ HumanReviewPacket,
34
+ build_human_review_packet,
35
+ build_human_decision_template,
36
+ import_human_decisions,
37
+ write_human_decision_template,
38
+ write_human_review_packet,
39
+ )
40
+ from .models import (
41
+ CapabilityCard,
42
+ CostEstimate,
43
+ CrupierResult,
44
+ DecisionTrace,
45
+ FileAsset,
46
+ FileRepresentation,
47
+ FileRoutingPlan,
48
+ ModelRef,
49
+ PlanningContext,
50
+ RequestEnvelope,
51
+ RoutePlan,
52
+ RouteStep,
53
+ )
54
+ from .orchestrator import DeterministicOrchestrator, ModelOrchestrator, Orchestrator
55
+ from .project_audit import (
56
+ AdoptionOption,
57
+ AdoptionHandoffReport,
58
+ AdoptionPatchReport,
59
+ AdoptionPatchSuggestion,
60
+ AuditCheck,
61
+ CodeComment,
62
+ CodeCommentReviewSummary,
63
+ DoctorGate,
64
+ ProjectAdoptionPlan,
65
+ ProjectAuditReport,
66
+ ProjectDoctorReport,
67
+ ProjectAuditRunner,
68
+ RouteReview,
69
+ acknowledge_code_comments,
70
+ build_adoption_handoff,
71
+ build_adoption_handoff_from_doctor,
72
+ build_adoption_patches,
73
+ build_adoption_plan,
74
+ build_config_free_adoption_handoff,
75
+ build_config_free_project_doctor,
76
+ build_project_doctor,
77
+ import_code_comment_decisions,
78
+ read_adoption_signoffs,
79
+ record_adoption_signoff,
80
+ summarize_applied_human_feedback,
81
+ summarize_adoption_signoffs,
82
+ summarize_code_comment_reviews,
83
+ write_adoption_package_index,
84
+ write_code_comment_decision_template,
85
+ write_code_comments_sarif,
86
+ write_code_review_comments,
87
+ )
88
+ from .probes import CapabilityProbeRunner, ProbeReport, ProbeResult, ReadinessItem, ReadinessReport
89
+ from .trace_store import StoredTraceRef, TraceStore
90
+ from .version import __version__
91
+
92
+ __all__ = [
93
+ "__version__",
94
+ "CapabilityCard",
95
+ "CapabilityProbeRunner",
96
+ "CostEstimate",
97
+ "CompareDatasetCaseResult",
98
+ "CompareDatasetModelScore",
99
+ "CompareDatasetReport",
100
+ "CompareHistoryReport",
101
+ "CompareRunReport",
102
+ "CompareVariant",
103
+ "CompareVariantResult",
104
+ "Crupier",
105
+ "CrupierBudgetExceededError",
106
+ "CrupierConfigError",
107
+ "CrupierError",
108
+ "CrupierModelUnsupportedError",
109
+ "CrupierPolicyError",
110
+ "CrupierProviderAuthError",
111
+ "CrupierProviderRateLimitError",
112
+ "CrupierProviderUnavailableError",
113
+ "CrupierResult",
114
+ "CrupierRouteValidationError",
115
+ "CrupierStructuredOutputError",
116
+ "CrupierToolApprovalRequired",
117
+ "CrupierUpdateRequiresConfirmation",
118
+ "DecisionTrace",
119
+ "FileAsset",
120
+ "FileRepresentation",
121
+ "FileRoutingPlan",
122
+ "HumanDecisionImportResult",
123
+ "HumanFeedbackRecord",
124
+ "HumanFeedbackStore",
125
+ "HumanReviewItem",
126
+ "HumanReviewPacket",
127
+ "build_human_decision_template",
128
+ "build_human_review_packet",
129
+ "import_human_decisions",
130
+ "install",
131
+ "ModelRef",
132
+ "PlanningContext",
133
+ "DeterministicOrchestrator",
134
+ "ModelOrchestrator",
135
+ "Orchestrator",
136
+ "AuditCheck",
137
+ "AdoptionHandoffReport",
138
+ "AdoptionOption",
139
+ "AdoptionPatchReport",
140
+ "AdoptionPatchSuggestion",
141
+ "acknowledge_code_comments",
142
+ "build_adoption_handoff",
143
+ "build_adoption_handoff_from_doctor",
144
+ "build_adoption_patches",
145
+ "build_adoption_plan",
146
+ "build_config_free_adoption_handoff",
147
+ "build_config_free_project_doctor",
148
+ "build_project_doctor",
149
+ "import_code_comment_decisions",
150
+ "read_adoption_signoffs",
151
+ "record_adoption_signoff",
152
+ "CodeComment",
153
+ "CodeCommentReviewSummary",
154
+ "DoctorGate",
155
+ "ProbeReport",
156
+ "ProbeResult",
157
+ "ProjectAuditReport",
158
+ "ProjectDoctorReport",
159
+ "ProjectAuditRunner",
160
+ "ProjectAdoptionPlan",
161
+ "ReadinessItem",
162
+ "ReadinessReport",
163
+ "RequestEnvelope",
164
+ "RoutePlan",
165
+ "RouteStep",
166
+ "RouteReview",
167
+ "StoredTraceRef",
168
+ "summarize_applied_human_feedback",
169
+ "summarize_adoption_signoffs",
170
+ "summarize_code_comment_reviews",
171
+ "write_adoption_package_index",
172
+ "write_code_comment_decision_template",
173
+ "write_code_comments_sarif",
174
+ "write_code_review_comments",
175
+ "TraceStore",
176
+ "write_human_decision_template",
177
+ "write_human_review_packet",
178
+ ]
crupier/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run Crupier as ``python -m crupier``."""
2
+
3
+ from .cli import main
4
+
5
+
6
+ raise SystemExit(main())
@@ -0,0 +1,14 @@
1
+ """Provider adapters."""
2
+
3
+ from .base import AdapterResponse, EmbeddingResponse, ProviderAdapter, ProviderModel
4
+ from .factory import build_default_adapters
5
+ from .openrouter import OpenRouterAdapter
6
+
7
+ __all__ = [
8
+ "AdapterResponse",
9
+ "EmbeddingResponse",
10
+ "OpenRouterAdapter",
11
+ "ProviderAdapter",
12
+ "ProviderModel",
13
+ "build_default_adapters",
14
+ ]
@@ -0,0 +1,267 @@
1
+ """Anthropic Claude Messages API adapter."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from crupier.config import ProviderSettings
8
+ from crupier.errors import (
9
+ CrupierProviderAuthError,
10
+ CrupierProviderRateLimitError,
11
+ CrupierProviderUnavailableError,
12
+ )
13
+ from crupier.models import RequestEnvelope
14
+ from crupier.multimodal import native_image_payloads
15
+
16
+ from .base import AdapterResponse, ProviderModel
17
+ from .common import (
18
+ build_prompt,
19
+ extract_anthropic_text,
20
+ object_to_dict,
21
+ provider_timeout_seconds,
22
+ request_timeout_seconds,
23
+ require_api_key,
24
+ )
25
+
26
+
27
+ class AnthropicAdapter:
28
+ provider = "anthropic"
29
+
30
+ def __init__(self, settings: ProviderSettings):
31
+ self.settings = settings
32
+ self._client: Any = None
33
+
34
+ def generate(self, *, model: str, prompt: str, request: RequestEnvelope) -> AdapterResponse:
35
+ client = self._client or self._build_client()
36
+ self._client = client
37
+ max_tokens = int(request.constraints.get("max_output_tokens", request.constraints.get("max_tokens", 1024)))
38
+ content: Any = prompt or build_prompt(request)
39
+ image_payloads = []
40
+ if request.files:
41
+ image_payloads = native_image_payloads(request.files)
42
+ content = [{"type": "text", "text": content}]
43
+ content.extend(
44
+ {
45
+ "type": "image",
46
+ "source": {
47
+ "type": "base64",
48
+ "media_type": image["mime_type"],
49
+ "data": image["base64"],
50
+ },
51
+ }
52
+ for image in image_payloads
53
+ )
54
+ payload: dict[str, Any] = {
55
+ "model": model,
56
+ "max_tokens": max_tokens,
57
+ "messages": [{"role": "user", "content": content}],
58
+ }
59
+ if "temperature" in request.constraints:
60
+ payload["temperature"] = request.constraints["temperature"]
61
+ timeout = request_timeout_seconds(request)
62
+ if timeout is not None:
63
+ payload["timeout"] = timeout
64
+
65
+ message, removed_params = self._messages_create_with_param_repair(client, payload)
66
+
67
+ text = extract_anthropic_text(message)
68
+ usage = object_to_dict(getattr(message, "usage", None))
69
+ return AdapterResponse(
70
+ text=text,
71
+ raw=message,
72
+ usage=usage,
73
+ metadata={
74
+ "provider": self.provider,
75
+ "model": model,
76
+ "api": "messages.create",
77
+ "removed_params": removed_params,
78
+ "multimodal_images": len(image_payloads),
79
+ },
80
+ )
81
+
82
+ def list_models(self) -> list[ProviderModel]:
83
+ client = self._client or self._build_client()
84
+ self._client = client
85
+ try:
86
+ response = client.models.list()
87
+ except AttributeError as exc:
88
+ raise CrupierProviderUnavailableError(
89
+ "Installed anthropic SDK does not expose client.models.list(); upgrade the optional dependency."
90
+ ) from exc
91
+ except Exception as exc: # noqa: BLE001
92
+ self._raise_mapped_error(exc)
93
+
94
+ data = getattr(response, "data", response)
95
+ models: list[ProviderModel] = []
96
+ for item in data:
97
+ model_id = item.get("id") if isinstance(item, dict) else getattr(item, "id", None)
98
+ if not model_id:
99
+ continue
100
+ name = item.get("display_name") if isinstance(item, dict) else getattr(item, "display_name", None)
101
+ metadata = item if isinstance(item, dict) else object_to_dict(item)
102
+ models.append(ProviderModel(id=str(model_id), provider=self.provider, name=name, metadata=metadata))
103
+ return sorted(models, key=lambda model: model.id)
104
+
105
+ def probe_capability(self, *, model: str, probe: str, request: RequestEnvelope) -> AdapterResponse:
106
+ if probe == "structured_output":
107
+ return self._probe_tool_schema(model=model, request=request, capability="structured_output")
108
+ if probe == "tool_call":
109
+ return self._probe_tool_schema(model=model, request=request, capability="tool_call")
110
+ if probe == "streaming":
111
+ return self._probe_streaming(model=model, request=request)
112
+ raise NotImplementedError(f"Anthropic adapter has no native probe registered for {probe!r}.")
113
+
114
+ def _probe_tool_schema(self, *, model: str, request: RequestEnvelope, capability: str) -> AdapterResponse:
115
+ client = self._client or self._build_client()
116
+ self._client = client
117
+ tool_name = "crupier_probe_tool"
118
+ payload = {
119
+ "model": model,
120
+ "max_tokens": int(request.constraints.get("max_output_tokens", 128)),
121
+ "messages": [{"role": "user", "content": "Use the crupier_probe_tool with ok=true and probe='crupier'."}],
122
+ "tools": [
123
+ {
124
+ "name": tool_name,
125
+ "description": "Report that the capability probe succeeded.",
126
+ "input_schema": _probe_schema(),
127
+ }
128
+ ],
129
+ "tool_choice": {"type": "tool", "name": tool_name},
130
+ }
131
+ timeout = request_timeout_seconds(request)
132
+ if timeout is not None:
133
+ payload["timeout"] = timeout
134
+ try:
135
+ message = client.messages.create(**payload)
136
+ except Exception as exc: # noqa: BLE001
137
+ self._raise_mapped_error(exc)
138
+ ok = _anthropic_has_tool_use(message, tool_name)
139
+ return AdapterResponse(
140
+ text="",
141
+ raw=message,
142
+ usage=object_to_dict(getattr(message, "usage", None)),
143
+ metadata={
144
+ "provider": self.provider,
145
+ "model": model,
146
+ "api": "messages.create",
147
+ "native_probe": True,
148
+ "capability": capability,
149
+ "ok": ok,
150
+ "probe_status": "verified" if ok else "failed",
151
+ "tool_name": tool_name,
152
+ },
153
+ )
154
+
155
+ def _probe_streaming(self, *, model: str, request: RequestEnvelope) -> AdapterResponse:
156
+ client = self._client or self._build_client()
157
+ self._client = client
158
+ try:
159
+ payload: dict[str, Any] = {
160
+ "model": model,
161
+ "max_tokens": 16,
162
+ "messages": [{"role": "user", "content": 'Reply with exactly: "stream-ok"'}],
163
+ "stream": True,
164
+ }
165
+ timeout = request_timeout_seconds(request)
166
+ if timeout is not None:
167
+ payload["timeout"] = timeout
168
+ stream = client.messages.create(**payload)
169
+ event_count = 0
170
+ text_seen = False
171
+ for event in stream:
172
+ event_count += 1
173
+ if _event_has_text(event):
174
+ text_seen = True
175
+ if event_count >= 20 and text_seen:
176
+ break
177
+ except Exception as exc: # noqa: BLE001
178
+ self._raise_mapped_error(exc)
179
+ ok = event_count > 0
180
+ return AdapterResponse(
181
+ text="",
182
+ raw=None,
183
+ metadata={
184
+ "provider": self.provider,
185
+ "model": model,
186
+ "api": "messages.create",
187
+ "native_probe": True,
188
+ "ok": ok,
189
+ "probe_status": "verified" if ok else "failed",
190
+ "event_count": event_count,
191
+ "text_event_seen": text_seen,
192
+ },
193
+ )
194
+
195
+ def _build_client(self) -> Any:
196
+ api_key = require_api_key(self.settings, "ANTHROPIC_API_KEY", provider=self.provider)
197
+ try:
198
+ from anthropic import Anthropic
199
+ except ImportError as exc:
200
+ raise CrupierProviderUnavailableError(
201
+ "Anthropic adapter requires the optional dependency: pip install 'crupier[anthropic]'.",
202
+ retryable=False,
203
+ ) from exc
204
+ kwargs: dict[str, Any] = {"api_key": api_key}
205
+ if self.settings.host:
206
+ kwargs["base_url"] = self.settings.host
207
+ timeout = provider_timeout_seconds(self.settings)
208
+ if timeout is not None:
209
+ kwargs["timeout"] = timeout
210
+ return Anthropic(**kwargs)
211
+
212
+ def _messages_create_with_param_repair(self, client: Any, payload: dict[str, Any]) -> tuple[Any, list[str]]:
213
+ try:
214
+ return client.messages.create(**payload), []
215
+ except Exception as exc: # noqa: BLE001 - provider SDK exceptions vary by version
216
+ if "temperature" in payload and _is_temperature_deprecated(exc):
217
+ repaired = dict(payload)
218
+ repaired.pop("temperature", None)
219
+ try:
220
+ return client.messages.create(**repaired), ["temperature"]
221
+ except Exception as repaired_exc: # noqa: BLE001
222
+ self._raise_mapped_error(repaired_exc)
223
+ self._raise_mapped_error(exc)
224
+
225
+ def _raise_mapped_error(self, exc: Exception) -> None:
226
+ name = exc.__class__.__name__.lower()
227
+ if "auth" in name or "permission" in name:
228
+ raise CrupierProviderAuthError(str(exc), provider=self.provider, env_key=self.settings.env_key) from exc
229
+ if "ratelimit" in name or "rate_limit" in name:
230
+ raise CrupierProviderRateLimitError(str(exc)) from exc
231
+ raise CrupierProviderUnavailableError(f"Anthropic request failed: {exc}") from exc
232
+
233
+
234
+ def _is_temperature_deprecated(exc: Exception) -> bool:
235
+ text = str(exc).lower()
236
+ return "temperature" in text and "deprecated" in text
237
+
238
+
239
+ def _probe_schema() -> dict[str, Any]:
240
+ return {
241
+ "type": "object",
242
+ "properties": {
243
+ "ok": {"type": "boolean"},
244
+ "probe": {"type": "string"},
245
+ },
246
+ "required": ["ok", "probe"],
247
+ "additionalProperties": False,
248
+ }
249
+
250
+
251
+ def _anthropic_has_tool_use(message: Any, tool_name: str) -> bool:
252
+ content = message.get("content", []) if isinstance(message, dict) else getattr(message, "content", [])
253
+ for block in content or []:
254
+ block_type = block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
255
+ name = block.get("name") if isinstance(block, dict) else getattr(block, "name", None)
256
+ if block_type == "tool_use" and name == tool_name:
257
+ return True
258
+ return False
259
+
260
+
261
+ def _event_has_text(event: Any) -> bool:
262
+ if isinstance(event, dict):
263
+ return bool(event.get("delta") or event.get("text") or event.get("content_block"))
264
+ for attr in ["delta", "text", "content_block"]:
265
+ if getattr(event, attr, None):
266
+ return True
267
+ return False
@@ -0,0 +1,83 @@
1
+ """Provider adapter contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+ from dataclasses import dataclass, field
7
+ from datetime import date, datetime
8
+ from typing import Any, Protocol
9
+
10
+ from crupier.models import RequestEnvelope
11
+
12
+
13
+ @dataclass(slots=True)
14
+ class AdapterResponse:
15
+ text: str
16
+ raw: Any = None
17
+ usage: dict[str, Any] = field(default_factory=dict)
18
+ metadata: dict[str, Any] = field(default_factory=dict)
19
+
20
+
21
+ @dataclass(slots=True)
22
+ class EmbeddingResponse:
23
+ embeddings: list[list[float]]
24
+ raw: Any = None
25
+ usage: dict[str, Any] = field(default_factory=dict)
26
+ metadata: dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ @dataclass(slots=True)
30
+ class ProviderModel:
31
+ id: str
32
+ provider: str
33
+ name: str | None = None
34
+ metadata: dict[str, Any] = field(default_factory=dict)
35
+
36
+ @property
37
+ def model_ref(self) -> str:
38
+ return f"{self.provider}:{self.id}"
39
+
40
+ def to_dict(self) -> dict[str, Any]:
41
+ return {
42
+ "id": self.id,
43
+ "provider": self.provider,
44
+ "model_ref": self.model_ref,
45
+ "name": self.name,
46
+ "metadata": _jsonable(self.metadata),
47
+ }
48
+
49
+
50
+ class ProviderAdapter(Protocol):
51
+ provider: str
52
+
53
+ def generate(self, *, model: str, prompt: str, request: RequestEnvelope) -> AdapterResponse:
54
+ """Generate text for a normalized prompt."""
55
+
56
+ def list_models(self) -> list[ProviderModel]:
57
+ """List models available to the configured account/provider."""
58
+
59
+ def probe_capability(self, *, model: str, probe: str, request: RequestEnvelope) -> AdapterResponse:
60
+ """Run a provider-native capability probe when supported."""
61
+
62
+ def embed(self, *, model: str, input: Any) -> EmbeddingResponse:
63
+ """Create embeddings when the provider/model supports it."""
64
+
65
+
66
+ def _jsonable(value: Any) -> Any:
67
+ if value is None or isinstance(value, str | int | float | bool):
68
+ return value
69
+ if isinstance(value, datetime | date):
70
+ return value.isoformat()
71
+ if isinstance(value, Mapping):
72
+ return {str(key): _jsonable(item) for key, item in value.items()}
73
+ if isinstance(value, set):
74
+ return sorted((_jsonable(item) for item in value), key=repr)
75
+ if isinstance(value, Sequence) and not isinstance(value, str | bytes | bytearray):
76
+ return [_jsonable(item) for item in value]
77
+ model_dump = getattr(value, "model_dump", None)
78
+ if callable(model_dump):
79
+ return _jsonable(model_dump())
80
+ to_dict = getattr(value, "to_dict", None)
81
+ if callable(to_dict):
82
+ return _jsonable(to_dict())
83
+ return repr(value)
@@ -0,0 +1,130 @@
1
+ """Shared adapter helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from typing import Any
8
+
9
+ from crupier.config import ProviderSettings
10
+ from crupier.errors import CrupierProviderAuthError
11
+ from crupier.models import RequestEnvelope
12
+
13
+
14
+ def env_value(settings: ProviderSettings, default_env_key: str, *, provider: str) -> str | None:
15
+ env_key = settings.env_key or default_env_key
16
+ value = os.environ.get(env_key)
17
+ if not value:
18
+ return None
19
+ return value
20
+
21
+
22
+ def require_api_key(settings: ProviderSettings, default_env_key: str, *, provider: str) -> str:
23
+ env_key = settings.env_key or default_env_key
24
+ value = os.environ.get(env_key)
25
+ if not value:
26
+ raise CrupierProviderAuthError(
27
+ f"Missing API key for provider {provider!r}.",
28
+ provider=provider,
29
+ env_key=env_key,
30
+ hint=f"Set {env_key} or update [providers.{provider}].env_key in crupier.toml.",
31
+ )
32
+ return value
33
+
34
+
35
+ def provider_timeout_seconds(settings: ProviderSettings, *, default: float | None = None) -> float | None:
36
+ value = settings.options.get("timeout_seconds", settings.options.get("timeout", default))
37
+ return _positive_float(value)
38
+
39
+
40
+ def request_timeout_seconds(request: RequestEnvelope, *, default: float | None = None) -> float | None:
41
+ value = request.constraints.get("timeout_seconds", request.constraints.get("timeout", default))
42
+ return _positive_float(value)
43
+
44
+
45
+ def _positive_float(value: Any) -> float | None:
46
+ if value is None:
47
+ return None
48
+ try:
49
+ number = float(value)
50
+ except (TypeError, ValueError):
51
+ return None
52
+ return number if number > 0 else None
53
+
54
+
55
+ def build_prompt(request: RequestEnvelope, *, extra: str | None = None) -> str:
56
+ parts = [f"Task:\n{request.task}"]
57
+ if request.messages:
58
+ parts.append("Messages:\n" + _format_input(request.messages))
59
+ if request.input is not None:
60
+ parts.append("Input:\n" + _format_input(request.input))
61
+ file_context = request.metadata.get("extracted_file_context") if request.metadata else None
62
+ if isinstance(file_context, dict) and file_context.get("body"):
63
+ parts.append("File context:\n" + str(file_context["body"]))
64
+ if extra:
65
+ parts.append(extra)
66
+ return "\n\n".join(parts)
67
+
68
+
69
+ def _format_input(value: Any) -> str:
70
+ if isinstance(value, str):
71
+ return value
72
+ try:
73
+ return json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True)
74
+ except TypeError:
75
+ return repr(value)
76
+
77
+
78
+ def extract_openai_text(response: Any) -> str:
79
+ output_text = getattr(response, "output_text", None)
80
+ if output_text:
81
+ return str(output_text)
82
+ if isinstance(response, dict):
83
+ if response.get("output_text"):
84
+ return str(response["output_text"])
85
+ output = response.get("output", [])
86
+ else:
87
+ output = getattr(response, "output", [])
88
+ chunks: list[str] = []
89
+ for item in output or []:
90
+ content = item.get("content", []) if isinstance(item, dict) else getattr(item, "content", [])
91
+ for block in content or []:
92
+ if isinstance(block, dict):
93
+ text = block.get("text") or block.get("output_text")
94
+ else:
95
+ text = getattr(block, "text", None)
96
+ if text:
97
+ chunks.append(str(text))
98
+ return "".join(chunks)
99
+
100
+
101
+ def extract_anthropic_text(message: Any) -> str:
102
+ content = message.get("content", []) if isinstance(message, dict) else getattr(message, "content", [])
103
+ chunks: list[str] = []
104
+ for block in content or []:
105
+ if isinstance(block, dict):
106
+ if block.get("type") == "text" and block.get("text"):
107
+ chunks.append(str(block["text"]))
108
+ else:
109
+ text = getattr(block, "text", None)
110
+ if text:
111
+ chunks.append(str(text))
112
+ return "".join(chunks)
113
+
114
+
115
+ def object_to_dict(value: Any) -> dict[str, Any]:
116
+ if value is None:
117
+ return {}
118
+ if isinstance(value, dict):
119
+ return value
120
+ model_dump = getattr(value, "model_dump", None)
121
+ if callable(model_dump):
122
+ return model_dump()
123
+ to_dict = getattr(value, "to_dict", None)
124
+ if callable(to_dict):
125
+ return to_dict()
126
+ attrs = {}
127
+ for name in ["input_tokens", "output_tokens", "total_tokens"]:
128
+ if hasattr(value, name):
129
+ attrs[name] = getattr(value, name)
130
+ return attrs