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.
Files changed (46) hide show
  1. b3_prosaic_runtime-0.8.1.dist-info/METADATA +650 -0
  2. b3_prosaic_runtime-0.8.1.dist-info/RECORD +46 -0
  3. b3_prosaic_runtime-0.8.1.dist-info/WHEEL +5 -0
  4. b3_prosaic_runtime-0.8.1.dist-info/entry_points.txt +2 -0
  5. b3_prosaic_runtime-0.8.1.dist-info/licenses/LICENSE +202 -0
  6. b3_prosaic_runtime-0.8.1.dist-info/licenses/LICENSE-MIT +21 -0
  7. b3_prosaic_runtime-0.8.1.dist-info/licenses/NOTICE.md +16 -0
  8. b3_prosaic_runtime-0.8.1.dist-info/top_level.txt +1 -0
  9. prosaic_runtime/__init__.py +27 -0
  10. prosaic_runtime/accounting.py +256 -0
  11. prosaic_runtime/accounting_capture.py +275 -0
  12. prosaic_runtime/admission.py +57 -0
  13. prosaic_runtime/anthropic.py +205 -0
  14. prosaic_runtime/anthropic_stream.py +179 -0
  15. prosaic_runtime/anthropic_usage.py +96 -0
  16. prosaic_runtime/artifacts.py +56 -0
  17. prosaic_runtime/budgets.py +67 -0
  18. prosaic_runtime/cli.py +153 -0
  19. prosaic_runtime/cli_tools.py +313 -0
  20. prosaic_runtime/config.py +127 -0
  21. prosaic_runtime/conformance.py +314 -0
  22. prosaic_runtime/console.py +37 -0
  23. prosaic_runtime/diagnostics.py +132 -0
  24. prosaic_runtime/events.py +56 -0
  25. prosaic_runtime/execution.py +442 -0
  26. prosaic_runtime/http_bounds.py +66 -0
  27. prosaic_runtime/openai_compatible.py +2373 -0
  28. prosaic_runtime/openai_compatible_compaction.py +219 -0
  29. prosaic_runtime/openai_compatible_filters.py +147 -0
  30. prosaic_runtime/openai_compatible_progress.py +405 -0
  31. prosaic_runtime/openai_compatible_transcript.py +79 -0
  32. prosaic_runtime/operation_context.py +39 -0
  33. prosaic_runtime/policy.py +50 -0
  34. prosaic_runtime/provider_types.py +19 -0
  35. prosaic_runtime/runtime.py +562 -0
  36. prosaic_runtime/sandbox.py +179 -0
  37. prosaic_runtime/smoke_prose/pilot.md +7 -0
  38. prosaic_runtime/smoke_prose/subagents/native-summarizer.md +10 -0
  39. prosaic_runtime/smoke_prose/subagents/reviewer.md +22 -0
  40. prosaic_runtime/smoke_prose/subagents/summarizer.md +11 -0
  41. prosaic_runtime/structured_tools.py +193 -0
  42. prosaic_runtime/telemetry.py +128 -0
  43. prosaic_runtime/tool_effects.py +112 -0
  44. prosaic_runtime/tool_registry.py +36 -0
  45. prosaic_runtime/tools.py +261 -0
  46. 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)