b3-prosaic-runtime 0.8.1__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.
- b3_prosaic_runtime-0.8.1.dist-info/METADATA +650 -0
- b3_prosaic_runtime-0.8.1.dist-info/RECORD +46 -0
- b3_prosaic_runtime-0.8.1.dist-info/WHEEL +5 -0
- b3_prosaic_runtime-0.8.1.dist-info/entry_points.txt +2 -0
- b3_prosaic_runtime-0.8.1.dist-info/licenses/LICENSE +202 -0
- b3_prosaic_runtime-0.8.1.dist-info/licenses/LICENSE-MIT +21 -0
- b3_prosaic_runtime-0.8.1.dist-info/licenses/NOTICE.md +16 -0
- b3_prosaic_runtime-0.8.1.dist-info/top_level.txt +1 -0
- prosaic_runtime/__init__.py +27 -0
- prosaic_runtime/accounting.py +256 -0
- prosaic_runtime/accounting_capture.py +275 -0
- prosaic_runtime/admission.py +57 -0
- prosaic_runtime/anthropic.py +205 -0
- prosaic_runtime/anthropic_stream.py +179 -0
- prosaic_runtime/anthropic_usage.py +96 -0
- prosaic_runtime/artifacts.py +56 -0
- prosaic_runtime/budgets.py +67 -0
- prosaic_runtime/cli.py +153 -0
- prosaic_runtime/cli_tools.py +313 -0
- prosaic_runtime/config.py +127 -0
- prosaic_runtime/conformance.py +314 -0
- prosaic_runtime/console.py +37 -0
- prosaic_runtime/diagnostics.py +132 -0
- prosaic_runtime/events.py +56 -0
- prosaic_runtime/execution.py +442 -0
- prosaic_runtime/http_bounds.py +66 -0
- prosaic_runtime/openai_compatible.py +2373 -0
- prosaic_runtime/openai_compatible_compaction.py +219 -0
- prosaic_runtime/openai_compatible_filters.py +147 -0
- prosaic_runtime/openai_compatible_progress.py +405 -0
- prosaic_runtime/openai_compatible_transcript.py +79 -0
- prosaic_runtime/operation_context.py +39 -0
- prosaic_runtime/policy.py +50 -0
- prosaic_runtime/provider_types.py +19 -0
- prosaic_runtime/runtime.py +562 -0
- prosaic_runtime/sandbox.py +179 -0
- prosaic_runtime/smoke_prose/pilot.md +7 -0
- prosaic_runtime/smoke_prose/subagents/native-summarizer.md +10 -0
- prosaic_runtime/smoke_prose/subagents/reviewer.md +22 -0
- prosaic_runtime/smoke_prose/subagents/summarizer.md +11 -0
- prosaic_runtime/structured_tools.py +193 -0
- prosaic_runtime/telemetry.py +128 -0
- prosaic_runtime/tool_effects.py +112 -0
- prosaic_runtime/tool_registry.py +36 -0
- prosaic_runtime/tools.py +261 -0
- prosaic_runtime/types.py +23 -0
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
"""Per-request capture independent of diagnostic sinks and cancellation."""
|
|
2
|
+
import json
|
|
3
|
+
import uuid
|
|
4
|
+
from .accounting import AccountingError, normalize_usage, utc_now
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class InvalidEvidence(ValueError):
|
|
8
|
+
pass
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def strict_json(raw):
|
|
12
|
+
def pairs(items):
|
|
13
|
+
result = {}
|
|
14
|
+
for key, value in items:
|
|
15
|
+
if key in result:
|
|
16
|
+
raise InvalidEvidence('duplicate response key')
|
|
17
|
+
result[key] = value
|
|
18
|
+
return result
|
|
19
|
+
def constant(value):
|
|
20
|
+
raise InvalidEvidence('nonfinite response value')
|
|
21
|
+
return json.loads(raw, object_pairs_hook=pairs, parse_constant=constant)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Capture:
|
|
25
|
+
def __init__(self, recorder, context, artifact, profile, provider='openai-compatible'):
|
|
26
|
+
self.recorder, self.context = recorder, context
|
|
27
|
+
self.artifact, self.profile = artifact, profile
|
|
28
|
+
self.call_ids = []
|
|
29
|
+
self.provider = provider
|
|
30
|
+
|
|
31
|
+
def open(self, opener, request, timeout, *, before_dispatch=None):
|
|
32
|
+
call_id = uuid.uuid4().hex
|
|
33
|
+
payload = json.loads(request.data)
|
|
34
|
+
card = self.recorder.rate_card
|
|
35
|
+
intent = {'version': 1, 'provider_call_id': call_id,
|
|
36
|
+
'namespace': self.recorder.namespace, 'environment': self.recorder.environment,
|
|
37
|
+
'context': self.context.to_dict(), 'artifact_id': self.artifact.id,
|
|
38
|
+
'artifact_sha256': self.artifact.digest, 'profile': self.profile,
|
|
39
|
+
'provider_id': self.recorder.provider_id, 'credential_account': self.recorder.credential_account,
|
|
40
|
+
'requested_model': payload['model'], 'started_at': utc_now(),
|
|
41
|
+
'rate_card': card.to_dict() if card else None}
|
|
42
|
+
try:
|
|
43
|
+
self.recorder.prepare(intent)
|
|
44
|
+
except Exception as exc:
|
|
45
|
+
raise AccountingError('intent persistence failed') from exc
|
|
46
|
+
self.call_ids.append(call_id)
|
|
47
|
+
observer = AnthropicObservation if self.provider == 'anthropic' else Observation
|
|
48
|
+
observation = observer(self.recorder, call_id)
|
|
49
|
+
try:
|
|
50
|
+
if before_dispatch is not None:
|
|
51
|
+
timeout = before_dispatch(timeout)
|
|
52
|
+
response = opener.open(request, timeout=timeout)
|
|
53
|
+
except BaseException as exc:
|
|
54
|
+
from .events import Cancelled
|
|
55
|
+
observation.finish('cancelled' if isinstance(exc, (Cancelled, KeyboardInterrupt)) else 'failed')
|
|
56
|
+
raise
|
|
57
|
+
return RecordedResponse(response, observation)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Observation:
|
|
61
|
+
eager_events = True
|
|
62
|
+
def __init__(self, recorder, call_id):
|
|
63
|
+
self.recorder, self.call_id = recorder, call_id
|
|
64
|
+
self.usage, self.model, self.request_id, self.tier = None, None, None, None
|
|
65
|
+
self.conflict = False
|
|
66
|
+
self.finished = False
|
|
67
|
+
self.terminal = False
|
|
68
|
+
|
|
69
|
+
def capture(self, event):
|
|
70
|
+
if type(event) is not dict:
|
|
71
|
+
return
|
|
72
|
+
choices = event.get('choices')
|
|
73
|
+
if type(choices) is list and any(type(choice) is dict and choice.get('finish_reason') in {'stop', 'tool_calls', 'length', 'content_filter'} for choice in choices):
|
|
74
|
+
self.terminal = True
|
|
75
|
+
for key, attr in (('model', 'model'), ('id', 'request_id'), ('service_tier', 'tier')):
|
|
76
|
+
value = event.get(key)
|
|
77
|
+
if type(value) is str and len(value) <= 256:
|
|
78
|
+
previous = getattr(self, attr)
|
|
79
|
+
if previous is not None and previous != value:
|
|
80
|
+
self.conflict = True
|
|
81
|
+
setattr(self, attr, value)
|
|
82
|
+
if event.get('usage') is not None:
|
|
83
|
+
usage = normalize_usage(event['usage'])
|
|
84
|
+
# Repeated snapshots are not deltas. Conflicting nonidentical usage
|
|
85
|
+
# is quarantined rather than choosing a potentially wrong bill.
|
|
86
|
+
if self.usage is not None and self.usage != usage:
|
|
87
|
+
self.conflict = True
|
|
88
|
+
self.usage = usage
|
|
89
|
+
|
|
90
|
+
def finish(self, outcome):
|
|
91
|
+
if self.finished:
|
|
92
|
+
return
|
|
93
|
+
usage = self.usage or normalize_usage(None)
|
|
94
|
+
if self.conflict:
|
|
95
|
+
usage = {**usage, 'status': 'untrusted'}
|
|
96
|
+
elif usage['status'] == 'reported' and not self.terminal:
|
|
97
|
+
usage = {**usage, 'status': 'partial'}
|
|
98
|
+
value = {'version': 1, 'provider_call_id': self.call_id,
|
|
99
|
+
'finished_at': utc_now(), 'outcome': outcome,
|
|
100
|
+
'response_model': self.model, 'provider_request_id': self.request_id,
|
|
101
|
+
'service_tier': self.tier, 'usage': usage}
|
|
102
|
+
try:
|
|
103
|
+
self.recorder.observe(self.call_id, value)
|
|
104
|
+
except Exception as exc:
|
|
105
|
+
raise AccountingError('observation persistence failed') from exc
|
|
106
|
+
self.finished = True
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class AnthropicObservation(Observation):
|
|
110
|
+
eager_events = False
|
|
111
|
+
|
|
112
|
+
def __init__(self, recorder, call_id):
|
|
113
|
+
super().__init__(recorder, call_id)
|
|
114
|
+
from .anthropic_stream import StreamState
|
|
115
|
+
self.state = StreamState(lambda text: None)
|
|
116
|
+
|
|
117
|
+
def capture(self, event):
|
|
118
|
+
from .anthropic import parse_message
|
|
119
|
+
from .anthropic_usage import normalize_anthropic_usage
|
|
120
|
+
if type(event) is not dict:
|
|
121
|
+
self.conflict = True
|
|
122
|
+
return
|
|
123
|
+
if event.get('type') == 'message':
|
|
124
|
+
message = event
|
|
125
|
+
self.usage = normalize_anthropic_usage(message.get('usage'))
|
|
126
|
+
try:
|
|
127
|
+
parse_message(message)
|
|
128
|
+
self.terminal = message.get('stop_reason') in {
|
|
129
|
+
'end_turn', 'stop_sequence', 'tool_use', 'max_tokens', 'refusal', 'pause_turn'}
|
|
130
|
+
except (ValueError, TypeError, RecursionError):
|
|
131
|
+
self.conflict = True
|
|
132
|
+
else:
|
|
133
|
+
try:
|
|
134
|
+
self.state.push(event)
|
|
135
|
+
if self.state.stopped:
|
|
136
|
+
parse_message(self.state.message)
|
|
137
|
+
self.terminal = True
|
|
138
|
+
except (ValueError, TypeError, RecursionError):
|
|
139
|
+
self.conflict = True
|
|
140
|
+
message = self.state.message or {}
|
|
141
|
+
self.usage = self.state.usage.normalized()
|
|
142
|
+
self.conflict |= self.state.usage.conflict
|
|
143
|
+
for key, attr in (('model', 'model'), ('id', 'request_id')):
|
|
144
|
+
value = message.get(key)
|
|
145
|
+
if type(value) is str and 0 < len(value) <= 256:
|
|
146
|
+
previous = getattr(self, attr)
|
|
147
|
+
if previous is not None and previous != value:
|
|
148
|
+
self.conflict = True
|
|
149
|
+
setattr(self, attr, value)
|
|
150
|
+
elif key in message:
|
|
151
|
+
self.conflict = True
|
|
152
|
+
raw_usage = message.get('usage')
|
|
153
|
+
if isinstance(raw_usage, dict) and raw_usage.get('service_tier') is not None:
|
|
154
|
+
tier = raw_usage['service_tier']
|
|
155
|
+
tier = 'default' if tier == 'standard' else tier
|
|
156
|
+
if not isinstance(tier, str) or len(tier) > 256 or (self.tier is not None and self.tier != tier):
|
|
157
|
+
self.conflict = True
|
|
158
|
+
else:
|
|
159
|
+
self.tier = tier
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class RecordedResponse:
|
|
163
|
+
def __init__(self, response, observation):
|
|
164
|
+
self.response, self.observation = response, observation
|
|
165
|
+
self.data = []
|
|
166
|
+
self.event_name = None
|
|
167
|
+
self.body = bytearray()
|
|
168
|
+
|
|
169
|
+
def __getattr__(self, name):
|
|
170
|
+
return getattr(self.response, name)
|
|
171
|
+
|
|
172
|
+
def __enter__(self):
|
|
173
|
+
self.response.__enter__()
|
|
174
|
+
return self
|
|
175
|
+
|
|
176
|
+
def __exit__(self, typ, value, traceback):
|
|
177
|
+
try:
|
|
178
|
+
if self.body:
|
|
179
|
+
self._capture_body(bytes(self.body))
|
|
180
|
+
self.body.clear()
|
|
181
|
+
if not self.observation.eager_events and (self.data or self.event_name is not None):
|
|
182
|
+
self.observation.conflict = True
|
|
183
|
+
self._flush()
|
|
184
|
+
self.observation.finish('cancelled' if typ and typ.__name__ in {'Cancelled', 'KeyboardInterrupt'}
|
|
185
|
+
else 'failed' if typ or not self.observation.terminal else 'completed')
|
|
186
|
+
finally:
|
|
187
|
+
return_value = self.response.__exit__(typ, value, traceback)
|
|
188
|
+
return return_value
|
|
189
|
+
|
|
190
|
+
def _flush(self):
|
|
191
|
+
if self.data:
|
|
192
|
+
try:
|
|
193
|
+
event = strict_json('\n'.join(self.data))
|
|
194
|
+
if self.event_name is not None and (not isinstance(event, dict) or event.get('type') != self.event_name):
|
|
195
|
+
self.observation.conflict = True
|
|
196
|
+
self.observation.capture(event)
|
|
197
|
+
except (ValueError, TypeError, RecursionError):
|
|
198
|
+
self.observation.conflict = True
|
|
199
|
+
self.data = []
|
|
200
|
+
elif self.event_name is not None:
|
|
201
|
+
self.observation.conflict = True
|
|
202
|
+
self.event_name = None
|
|
203
|
+
|
|
204
|
+
def _line(self, raw):
|
|
205
|
+
if self.observation.eager_events:
|
|
206
|
+
line = raw.decode('utf-8', errors='replace').strip()
|
|
207
|
+
else:
|
|
208
|
+
try:
|
|
209
|
+
line = raw.decode('utf-8', errors='strict').rstrip('\r\n')
|
|
210
|
+
except UnicodeError:
|
|
211
|
+
self.observation.conflict = True
|
|
212
|
+
return
|
|
213
|
+
if not line:
|
|
214
|
+
self._flush()
|
|
215
|
+
elif not self.observation.eager_events and line.startswith('event:'):
|
|
216
|
+
if self.event_name is not None:
|
|
217
|
+
self.observation.conflict = True
|
|
218
|
+
self.event_name = line[6:].lstrip()
|
|
219
|
+
elif line.startswith('data:'):
|
|
220
|
+
value = line[5:].lstrip()
|
|
221
|
+
if value == '[DONE]' and self.observation.eager_events:
|
|
222
|
+
self._flush()
|
|
223
|
+
else:
|
|
224
|
+
self.data.append(value)
|
|
225
|
+
if not self.observation.eager_events:
|
|
226
|
+
return
|
|
227
|
+
# Capture before the normal parser can emit a text callback
|
|
228
|
+
# that requests cancellation. Multi-line JSON remains buffered.
|
|
229
|
+
try:
|
|
230
|
+
event = strict_json('\n'.join(self.data))
|
|
231
|
+
except InvalidEvidence:
|
|
232
|
+
self.observation.conflict = True
|
|
233
|
+
self.data = []
|
|
234
|
+
return
|
|
235
|
+
except ValueError:
|
|
236
|
+
return
|
|
237
|
+
self.observation.capture(event)
|
|
238
|
+
self.data = []
|
|
239
|
+
|
|
240
|
+
def readline(self, *args):
|
|
241
|
+
raw = self.response.readline(*args)
|
|
242
|
+
self._line(raw)
|
|
243
|
+
return raw
|
|
244
|
+
|
|
245
|
+
def read_chunk(self, size):
|
|
246
|
+
"""Line assembly reads bounded chunks; evidence is admitted per line."""
|
|
247
|
+
return self.response.read1(size)
|
|
248
|
+
|
|
249
|
+
def capture_line(self, raw):
|
|
250
|
+
self._line(raw)
|
|
251
|
+
|
|
252
|
+
def read(self, *args):
|
|
253
|
+
raw = self.response.read(*args)
|
|
254
|
+
self._capture_body(raw)
|
|
255
|
+
return raw
|
|
256
|
+
|
|
257
|
+
def read1(self, *args):
|
|
258
|
+
raw = self.response.read1(*args)
|
|
259
|
+
self.body.extend(raw)
|
|
260
|
+
if not raw:
|
|
261
|
+
self._capture_body(bytes(self.body))
|
|
262
|
+
self.body.clear()
|
|
263
|
+
return raw
|
|
264
|
+
|
|
265
|
+
def _capture_body(self, raw):
|
|
266
|
+
try:
|
|
267
|
+
self.observation.capture(strict_json(raw))
|
|
268
|
+
except InvalidEvidence:
|
|
269
|
+
self.observation.conflict = True
|
|
270
|
+
except RecursionError:
|
|
271
|
+
self.observation.conflict = True
|
|
272
|
+
except (ValueError, TypeError):
|
|
273
|
+
for line in raw.splitlines():
|
|
274
|
+
self._line(line)
|
|
275
|
+
self._flush()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Pure execution-metadata checks for already inspected Prosaic artifacts."""
|
|
2
|
+
from .artifacts import ProsaicArtifact
|
|
3
|
+
from .config import EndpointConfig, RuntimeConfig
|
|
4
|
+
from .policy import requested_tools
|
|
5
|
+
from .anthropic import validate_anthropic_controls
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _inspection_artifact(artifact):
|
|
9
|
+
if not isinstance(artifact, ProsaicArtifact):
|
|
10
|
+
raise ValueError('execution admission requires a Prosaic inspection artifact')
|
|
11
|
+
try:
|
|
12
|
+
return ProsaicArtifact.from_inspection({
|
|
13
|
+
'id': artifact.id, 'type': artifact.type, 'frontmatter': artifact.frontmatter,
|
|
14
|
+
'body': artifact.body, 'resources': list(artifact.resources)})
|
|
15
|
+
except (TypeError, AttributeError) as exc:
|
|
16
|
+
raise ValueError('invalid Prosaic inspection artifact') from exc
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def validate_execution_artifact(artifact, config, *, acquisition=None) -> None:
|
|
20
|
+
"""Check structural and supported execution metadata without preparing a run.
|
|
21
|
+
|
|
22
|
+
Inputs must already be inspected artifacts and a RuntimeConfig. This function
|
|
23
|
+
does not inspect paths, discover tools, render prompts, construct a Runtime or
|
|
24
|
+
invoke callbacks/transports. Tool registration/grants and invocation-dependent
|
|
25
|
+
limits remain separate checks. Canonical prose fields belong to Prosaic.
|
|
26
|
+
"""
|
|
27
|
+
artifact = _inspection_artifact(artifact)
|
|
28
|
+
if not isinstance(config, RuntimeConfig):
|
|
29
|
+
raise ValueError('execution admission requires a RuntimeConfig')
|
|
30
|
+
requested = requested_tools(artifact.frontmatter.get('tools'))
|
|
31
|
+
effort = artifact.frontmatter.get('effort')
|
|
32
|
+
if effort is not None and (not isinstance(effort, str) or effort not in {'low', 'medium', 'high'}):
|
|
33
|
+
raise ValueError(f'unsupported effort: {effort}')
|
|
34
|
+
tier = artifact.frontmatter.get('model_tier')
|
|
35
|
+
try:
|
|
36
|
+
if tier is not None and (not isinstance(tier, str) or tier not in config.routes):
|
|
37
|
+
raise ValueError(f'no endpoint route for model_tier: {tier}')
|
|
38
|
+
profile = config.routes[tier] if tier is not None else config.default_profile
|
|
39
|
+
endpoint = config.profiles[profile]
|
|
40
|
+
if not isinstance(endpoint, EndpointConfig):
|
|
41
|
+
raise ValueError('selected profile requires an EndpointConfig')
|
|
42
|
+
# Runtime disables these controls regardless of operator configuration.
|
|
43
|
+
features = {**endpoint.features, 'web_tools': False, 'transcript': False}
|
|
44
|
+
except (TypeError, KeyError, AttributeError) as exc:
|
|
45
|
+
raise ValueError('invalid selected runtime profile or route') from exc
|
|
46
|
+
if endpoint.provider == 'anthropic':
|
|
47
|
+
validate_anthropic_controls(features, {'effort': effort})
|
|
48
|
+
if acquisition is not None:
|
|
49
|
+
acquisition = _inspection_artifact(acquisition)
|
|
50
|
+
acquisition_tools = requested_tools(acquisition.frontmatter.get('tools'))
|
|
51
|
+
if acquisition_tools - requested:
|
|
52
|
+
raise ValueError('acquisition tools cannot broaden final prose tools')
|
|
53
|
+
for key in ('model_tier', 'effort'):
|
|
54
|
+
if key in acquisition.frontmatter and acquisition.frontmatter[key] != artifact.frontmatter.get(key):
|
|
55
|
+
raise ValueError(f'acquisition {key} must match final prose or be omitted')
|
|
56
|
+
# Acquisitions use the selected final endpoint and effort, never a new
|
|
57
|
+
# route/default selection based on their omitted metadata.
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Native Anthropic Messages transport; tools execute only in the shared loop."""
|
|
2
|
+
import json
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from .accounting_capture import strict_json
|
|
9
|
+
from .execution import ExecutionBackend
|
|
10
|
+
from .provider_types import ProviderTurn
|
|
11
|
+
from .anthropic_usage import normalize_anthropic_usage
|
|
12
|
+
from .types import Result
|
|
13
|
+
from .tools import depth
|
|
14
|
+
from .openai_compatible import (
|
|
15
|
+
OpenAICompatibleBackend, _OpenAIToolRegistry, _api_key, _feature_enabled,
|
|
16
|
+
_http_status, _is_sse_response, _prompt_metadata, _raw_response_headers,
|
|
17
|
+
_tool_call_summary,
|
|
18
|
+
)
|
|
19
|
+
from .openai_compatible_transcript import ProviderTranscript
|
|
20
|
+
|
|
21
|
+
API_VERSION = '2023-06-01'
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def validate_anthropic_controls(features, prompt_metadata):
|
|
25
|
+
"""Validate known Messages controls without constructing a model request."""
|
|
26
|
+
unsupported = {'json_mode', 'reasoning_effort', 'effort', 'thinking', 'stream_options', 'web_tools'}
|
|
27
|
+
for name in unsupported:
|
|
28
|
+
value = features.get(name)
|
|
29
|
+
if value not in (None, False, 'off', 'false', 'disabled'):
|
|
30
|
+
raise ValueError(f'unsupported Anthropic feature: {name}')
|
|
31
|
+
if any(prompt_metadata.get(k) is not None for k in ('effort', 'reasoning_effort')):
|
|
32
|
+
raise ValueError('unsupported Anthropic effort control')
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def native_headers(token):
|
|
36
|
+
headers = {'Content-Type': 'application/json', 'anthropic-version': API_VERSION}
|
|
37
|
+
if token:
|
|
38
|
+
headers['x-api-key'] = token
|
|
39
|
+
return headers
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def encode_messages(payload):
|
|
43
|
+
"""Convert the loop's internal history after acquisition/tool-choice edits."""
|
|
44
|
+
messages, system = [], []
|
|
45
|
+
for message in payload['messages']:
|
|
46
|
+
role = message['role']
|
|
47
|
+
if role == 'system':
|
|
48
|
+
system.append(message['content'])
|
|
49
|
+
continue
|
|
50
|
+
if role == 'tool':
|
|
51
|
+
content = message['content']
|
|
52
|
+
block = {'type': 'tool_result', 'tool_use_id': message['tool_call_id'], 'content': content}
|
|
53
|
+
if json.loads(content).get('status') == 'error':
|
|
54
|
+
block['is_error'] = True
|
|
55
|
+
role, blocks = 'user', [block]
|
|
56
|
+
elif role == 'assistant' and 'provider_content' in message:
|
|
57
|
+
blocks = message['provider_content']
|
|
58
|
+
else:
|
|
59
|
+
blocks = [{'type': 'text', 'text': message['content']}]
|
|
60
|
+
if messages and messages[-1]['role'] == role:
|
|
61
|
+
messages[-1]['content'].extend(blocks)
|
|
62
|
+
else:
|
|
63
|
+
messages.append({'role': role, 'content': list(blocks)})
|
|
64
|
+
native = {'model': payload['model'], 'max_tokens': payload['max_tokens'], 'messages': messages}
|
|
65
|
+
if system:
|
|
66
|
+
native['system'] = '\n\n'.join(system)
|
|
67
|
+
if payload.get('temperature') is not None:
|
|
68
|
+
native['temperature'] = payload['temperature']
|
|
69
|
+
if payload.get('stream'):
|
|
70
|
+
native['stream'] = True
|
|
71
|
+
if payload.get('tools'):
|
|
72
|
+
native['tools'] = [{'name': t['function']['name'],
|
|
73
|
+
'description': t['function'].get('description', ''),
|
|
74
|
+
'input_schema': t['function']['parameters']} for t in payload['tools']]
|
|
75
|
+
choice = payload.get('tool_choice', 'auto')
|
|
76
|
+
native['tool_choice'] = ({'type': 'tool', 'name': choice['function']['name'],
|
|
77
|
+
'disable_parallel_tool_use': True}
|
|
78
|
+
if isinstance(choice, dict) else {'type': 'auto'})
|
|
79
|
+
return native
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def usage_details(raw):
|
|
83
|
+
usage = normalize_anthropic_usage(raw)
|
|
84
|
+
if usage['status'] in {'untrusted', 'unknown'} or usage['total_tokens'] is None:
|
|
85
|
+
return {}
|
|
86
|
+
return {'prompt_tokens': usage['input_tokens'], 'completion_tokens': usage['output_tokens'],
|
|
87
|
+
'total_tokens': usage['total_tokens']}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_message(value, *, streamed=False, http_status=200, headers=None, usage_conflict=False):
|
|
91
|
+
"""Validate native content before exposing any executable tool request."""
|
|
92
|
+
if (not isinstance(value, dict) or value.get('type') != 'message' or
|
|
93
|
+
value.get('role') != 'assistant' or not isinstance(value.get('content'), list) or
|
|
94
|
+
not isinstance(value.get('id'), str) or not value['id'] or
|
|
95
|
+
not isinstance(value.get('model'), str) or not value['model']):
|
|
96
|
+
raise ValueError('invalid Messages response')
|
|
97
|
+
depth(value)
|
|
98
|
+
text, calls, ids = [], [], set()
|
|
99
|
+
for block in value['content']:
|
|
100
|
+
if not isinstance(block, dict):
|
|
101
|
+
raise ValueError('invalid content block')
|
|
102
|
+
if block.get('type') == 'text' and isinstance(block.get('text'), str):
|
|
103
|
+
text.append(block['text'])
|
|
104
|
+
elif block.get('type') == 'tool_use':
|
|
105
|
+
if (not isinstance(block.get('id'), str) or not block['id'] or block['id'] in ids or
|
|
106
|
+
not isinstance(block.get('name'), str) or not block['name'] or
|
|
107
|
+
type(block.get('input')) is not dict):
|
|
108
|
+
raise ValueError('invalid tool_use block')
|
|
109
|
+
ids.add(block['id'])
|
|
110
|
+
calls.append({'id': block['id'], 'type': 'function', 'function': {
|
|
111
|
+
'name': block['name'], 'arguments': json.dumps(block['input'], allow_nan=False)}})
|
|
112
|
+
else:
|
|
113
|
+
raise ValueError('unsupported content block')
|
|
114
|
+
details = {} if usage_conflict else usage_details(value.get('usage'))
|
|
115
|
+
raw = {k: value[k] for k in ('id', 'model', 'stop_reason', 'stop_sequence', 'usage') if k in value}
|
|
116
|
+
raw['usage_status'] = 'untrusted' if usage_conflict else normalize_anthropic_usage(value.get('usage'))['status']
|
|
117
|
+
metadata = {'provider': 'anthropic', 'streamed': streamed, 'http_status': http_status,
|
|
118
|
+
'raw_response_headers': headers or {}, 'raw_response_metadata': raw,
|
|
119
|
+
'token_usage_details': details}
|
|
120
|
+
stop = value.get('stop_reason')
|
|
121
|
+
if stop not in {'end_turn', 'stop_sequence', 'tool_use'}:
|
|
122
|
+
code = 'incomplete_generation' if stop == 'max_tokens' else 'unsupported_stop_reason'
|
|
123
|
+
return Result(1, ''.join(text), 'Anthropic did not complete the requested turn',
|
|
124
|
+
token_usage=details.get('total_tokens'), metadata={**metadata,
|
|
125
|
+
'provider_error_code': code, 'failure_reason': code, 'finish_reason': stop})
|
|
126
|
+
if bool(calls) != (stop == 'tool_use'):
|
|
127
|
+
raise ValueError('tool use and stop reason disagree')
|
|
128
|
+
return ProviderTurn(''.join(text), 'tool_calls' if calls else 'stop', details.get('total_tokens', 0),
|
|
129
|
+
details, raw, False, calls, streamed, http_status, headers or {},
|
|
130
|
+
continuation=value['content'])
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class AnthropicBackend(ExecutionBackend):
|
|
134
|
+
name = 'anthropic'
|
|
135
|
+
|
|
136
|
+
def __init__(self, config):
|
|
137
|
+
self._config = config
|
|
138
|
+
|
|
139
|
+
def make_registry(self, cwd, features, metadata):
|
|
140
|
+
return _OpenAIToolRegistry(cwd, features, metadata)
|
|
141
|
+
|
|
142
|
+
def tool_call_summary(self, call):
|
|
143
|
+
return _tool_call_summary(call)
|
|
144
|
+
|
|
145
|
+
def tool_event_metadata(self, name):
|
|
146
|
+
return {}
|
|
147
|
+
|
|
148
|
+
def strict_tool_names(self):
|
|
149
|
+
return frozenset()
|
|
150
|
+
|
|
151
|
+
def open_transcript(self, request):
|
|
152
|
+
return ProviderTranscript(None)
|
|
153
|
+
|
|
154
|
+
def tool_guidance(self):
|
|
155
|
+
return OpenAICompatibleBackend.tool_guidance(self)
|
|
156
|
+
|
|
157
|
+
def run_prompt(self, request):
|
|
158
|
+
return self._run_prompt_with_tools(request, _prompt_metadata(request),
|
|
159
|
+
_feature_enabled(self._config.features, 'streaming', default=True))
|
|
160
|
+
|
|
161
|
+
def _chat_payload(self, messages, prompt_metadata, *, streaming, tools=None):
|
|
162
|
+
validate_anthropic_controls(self._config.features, prompt_metadata)
|
|
163
|
+
payload = OpenAICompatibleBackend._chat_payload(self, messages, prompt_metadata, streaming=streaming, tools=tools)
|
|
164
|
+
if type(payload.get('max_tokens')) is not int or payload['max_tokens'] <= 0:
|
|
165
|
+
raise ValueError('Anthropic max_tokens must be a positive integer')
|
|
166
|
+
payload.pop('stream_options', None)
|
|
167
|
+
return payload
|
|
168
|
+
|
|
169
|
+
def _post_chat_turn(self, payload, request, deadline, streaming):
|
|
170
|
+
native = encode_messages(payload)
|
|
171
|
+
self.validate_payload(native)
|
|
172
|
+
token, error = _api_key(self._config.api_key_env, self._config.api_key_file, request.env)
|
|
173
|
+
if error:
|
|
174
|
+
return Result(1, '', error, metadata={'provider': self.name, 'provider_error_code': 'api_key_file_error'})
|
|
175
|
+
http_request = urllib.request.Request(self._config.base_url.rstrip('/') + '/messages',
|
|
176
|
+
data=json.dumps(native, allow_nan=False).encode('utf-8'), headers=native_headers(token), method='POST')
|
|
177
|
+
try:
|
|
178
|
+
with self.open_http(http_request, timeout=max(0.001, min(request.timeout_s, deadline - time.monotonic()))) as response:
|
|
179
|
+
status, headers = _http_status(response), _raw_response_headers(response)
|
|
180
|
+
if streaming and _is_sse_response(response):
|
|
181
|
+
return self._read_sse_turn(response, deadline)
|
|
182
|
+
body = self.read_response(response)
|
|
183
|
+
except (TimeoutError, socket.timeout):
|
|
184
|
+
return Result(1, '', 'Anthropic request timed out', timed_out=True,
|
|
185
|
+
metadata={'provider': self.name, 'provider_error_code': 'timeout'})
|
|
186
|
+
except UnicodeError:
|
|
187
|
+
return Result(1, '', 'Malformed Anthropic response', metadata={
|
|
188
|
+
'provider': self.name, 'provider_error_code': 'malformed_response'})
|
|
189
|
+
except urllib.error.HTTPError as exc:
|
|
190
|
+
exc.close()
|
|
191
|
+
return Result(exc.code, '', 'Anthropic request rejected', metadata={
|
|
192
|
+
'provider': self.name, 'http_status': exc.code, 'provider_error_code': 'http_error'})
|
|
193
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
194
|
+
return Result(1, '', 'Anthropic transport failed', metadata={
|
|
195
|
+
'provider': self.name, 'provider_error_code': 'url_error'})
|
|
196
|
+
try:
|
|
197
|
+
return parse_message(strict_json(body), http_status=status, headers=headers)
|
|
198
|
+
except (ValueError, TypeError, UnicodeError, RecursionError):
|
|
199
|
+
return Result(1, '', 'Malformed or unsupported Anthropic response', metadata={
|
|
200
|
+
'provider': self.name, 'http_status': status, 'provider_error_code': 'malformed_response'})
|
|
201
|
+
|
|
202
|
+
def _read_sse_turn(self, response, deadline):
|
|
203
|
+
# Implemented separately from JSON framing; partial tool inputs are never executable.
|
|
204
|
+
from .anthropic_stream import read_anthropic_turn
|
|
205
|
+
return read_anthropic_turn(response, deadline)
|