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 +178 -0
- crupier/__main__.py +6 -0
- crupier/adapters/__init__.py +14 -0
- crupier/adapters/anthropic.py +267 -0
- crupier/adapters/base.py +83 -0
- crupier/adapters/common.py +130 -0
- crupier/adapters/factory.py +29 -0
- crupier/adapters/google.py +453 -0
- crupier/adapters/ollama.py +419 -0
- crupier/adapters/openai.py +366 -0
- crupier/adapters/openrouter.py +60 -0
- crupier/autopatch.py +44 -0
- crupier/capabilities.py +53 -0
- crupier/cli.py +2690 -0
- crupier/client.py +240 -0
- crupier/compat/__init__.py +5 -0
- crupier/compat/openai.py +503 -0
- crupier/config.py +421 -0
- crupier/costs.py +116 -0
- crupier/default_cards.py +234 -0
- crupier/errors.py +59 -0
- crupier/evals.py +985 -0
- crupier/executor.py +547 -0
- crupier/feedback.py +811 -0
- crupier/models.py +515 -0
- crupier/multimodal.py +514 -0
- crupier/orchestrator.py +520 -0
- crupier/planner.py +51 -0
- crupier/policy.py +184 -0
- crupier/probes.py +555 -0
- crupier/project_audit.py +3277 -0
- crupier/py.typed +1 -0
- crupier/registry.py +733 -0
- crupier/release.py +2336 -0
- crupier/route_schema.py +102 -0
- crupier/selector.py +234 -0
- crupier/server.py +353 -0
- crupier/structured.py +165 -0
- crupier/tools.py +296 -0
- crupier/trace_store.py +290 -0
- crupier/version.py +3 -0
- crupier-0.1.0.dist-info/METADATA +869 -0
- crupier-0.1.0.dist-info/RECORD +47 -0
- crupier-0.1.0.dist-info/WHEEL +5 -0
- crupier-0.1.0.dist-info/entry_points.txt +2 -0
- crupier-0.1.0.dist-info/licenses/LICENSE +21 -0
- crupier-0.1.0.dist-info/top_level.txt +1 -0
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,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
|
crupier/adapters/base.py
ADDED
|
@@ -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
|