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,179 @@
|
|
|
1
|
+
"""Strict native SSE assembly; tool blocks leave this module only when complete."""
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
from .accounting_capture import strict_json
|
|
5
|
+
from .anthropic import parse_message
|
|
6
|
+
from .anthropic_usage import UsageSnapshots
|
|
7
|
+
from .events import check_cancelled, emit
|
|
8
|
+
from .openai_compatible import _http_status, _raw_response_headers
|
|
9
|
+
from .types import Result
|
|
10
|
+
from .http_bounds import LimitExceeded, set_response_timeout
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class StreamState:
|
|
14
|
+
def __init__(self, on_text):
|
|
15
|
+
self.message = None
|
|
16
|
+
self.blocks = []
|
|
17
|
+
self.active = None
|
|
18
|
+
self.arguments = ''
|
|
19
|
+
self.delta_seen = False
|
|
20
|
+
self.stopped = False
|
|
21
|
+
self.on_text = on_text
|
|
22
|
+
self.usage = UsageSnapshots()
|
|
23
|
+
|
|
24
|
+
def push(self, event):
|
|
25
|
+
if not isinstance(event, dict) or not isinstance(event.get('type'), str):
|
|
26
|
+
raise ValueError('invalid stream event')
|
|
27
|
+
kind = event['type']
|
|
28
|
+
if self.stopped:
|
|
29
|
+
raise ValueError('event after message_stop')
|
|
30
|
+
if kind == 'ping':
|
|
31
|
+
return
|
|
32
|
+
if kind == 'message_start':
|
|
33
|
+
message = event.get('message')
|
|
34
|
+
if (self.message is not None or not isinstance(message, dict) or
|
|
35
|
+
message.get('type') != 'message' or message.get('role') != 'assistant' or
|
|
36
|
+
message.get('content') != [] or message.get('stop_reason') is not None or
|
|
37
|
+
not isinstance(message.get('id'), str) or not message['id'] or
|
|
38
|
+
not isinstance(message.get('model'), str) or not message['model']):
|
|
39
|
+
raise ValueError('invalid message_start')
|
|
40
|
+
self.message = {**message, 'content': self.blocks}
|
|
41
|
+
self.usage.update(message.get('usage'))
|
|
42
|
+
return
|
|
43
|
+
if self.message is None:
|
|
44
|
+
raise ValueError('missing message_start')
|
|
45
|
+
if kind == 'content_block_start':
|
|
46
|
+
index, block = event.get('index'), event.get('content_block')
|
|
47
|
+
if (self.delta_seen or self.active is not None or type(index) is not int or
|
|
48
|
+
index != len(self.blocks) or not isinstance(block, dict)):
|
|
49
|
+
raise ValueError('invalid block start')
|
|
50
|
+
if block.get('type') == 'text' and isinstance(block.get('text'), str):
|
|
51
|
+
if block['text']:
|
|
52
|
+
self.on_text(block['text'])
|
|
53
|
+
elif block.get('type') == 'tool_use' and type(block.get('input')) is dict:
|
|
54
|
+
if block['input']:
|
|
55
|
+
raise ValueError('stream tool input must start empty')
|
|
56
|
+
else:
|
|
57
|
+
raise ValueError('unsupported content block')
|
|
58
|
+
self.blocks.append(dict(block))
|
|
59
|
+
self.active, self.arguments = index, ''
|
|
60
|
+
return
|
|
61
|
+
if kind in {'content_block_delta', 'content_block_stop'}:
|
|
62
|
+
index = event.get('index')
|
|
63
|
+
if type(index) is not int or self.active is None or index != self.active or self.delta_seen:
|
|
64
|
+
raise ValueError('inactive block')
|
|
65
|
+
block = self.blocks[index]
|
|
66
|
+
if kind == 'content_block_stop':
|
|
67
|
+
if block['type'] == 'tool_use' and self.arguments:
|
|
68
|
+
value = strict_json(self.arguments)
|
|
69
|
+
if type(value) is not dict:
|
|
70
|
+
raise ValueError('tool input must be an object')
|
|
71
|
+
block['input'] = value
|
|
72
|
+
self.active, self.arguments = None, ''
|
|
73
|
+
return
|
|
74
|
+
delta = event.get('delta')
|
|
75
|
+
if not isinstance(delta, dict):
|
|
76
|
+
raise ValueError('invalid delta')
|
|
77
|
+
if (block['type'] == 'text' and delta.get('type') == 'text_delta' and
|
|
78
|
+
isinstance(delta.get('text'), str)):
|
|
79
|
+
block['text'] += delta['text']
|
|
80
|
+
if delta['text']:
|
|
81
|
+
self.on_text(delta['text'])
|
|
82
|
+
elif (block['type'] == 'tool_use' and delta.get('type') == 'input_json_delta' and
|
|
83
|
+
isinstance(delta.get('partial_json'), str)):
|
|
84
|
+
self.arguments += delta['partial_json']
|
|
85
|
+
else:
|
|
86
|
+
raise ValueError('unsupported block delta')
|
|
87
|
+
return
|
|
88
|
+
if kind == 'message_delta':
|
|
89
|
+
delta = event.get('delta')
|
|
90
|
+
if self.active is not None or not isinstance(delta, dict):
|
|
91
|
+
raise ValueError('invalid message delta')
|
|
92
|
+
if set(delta) - {'stop_reason', 'stop_sequence'}:
|
|
93
|
+
raise ValueError('unsupported message delta')
|
|
94
|
+
previous = self.message.get('stop_reason')
|
|
95
|
+
stop = delta.get('stop_reason')
|
|
96
|
+
if previous is not None and stop is not None and previous != stop:
|
|
97
|
+
raise ValueError('conflicting stop reason')
|
|
98
|
+
self.message.update(delta)
|
|
99
|
+
usage = event.get('usage')
|
|
100
|
+
if usage is not None:
|
|
101
|
+
if not isinstance(usage, dict) or not isinstance(self.message.get('usage', {}), dict):
|
|
102
|
+
raise ValueError('invalid usage')
|
|
103
|
+
self.usage.update(usage)
|
|
104
|
+
self.message['usage'] = self.usage.raw
|
|
105
|
+
self.delta_seen = True
|
|
106
|
+
return
|
|
107
|
+
if kind == 'message_stop':
|
|
108
|
+
if self.active is not None or not self.delta_seen or self.message.get('stop_reason') is None:
|
|
109
|
+
raise ValueError('incomplete message')
|
|
110
|
+
self.stopped = True
|
|
111
|
+
return
|
|
112
|
+
# Unknown consequential events cannot be assumed to preserve execution semantics.
|
|
113
|
+
raise ValueError('unsupported stream event')
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _emit_text(text):
|
|
117
|
+
emit('text_delta', text=text)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_anthropic_turn(response, deadline, *, on_text=_emit_text):
|
|
121
|
+
state = StreamState(on_text)
|
|
122
|
+
data, event_name = [], None
|
|
123
|
+
status, headers = _http_status(response), _raw_response_headers(response)
|
|
124
|
+
|
|
125
|
+
def flush():
|
|
126
|
+
nonlocal data, event_name
|
|
127
|
+
if data:
|
|
128
|
+
event = strict_json('\n'.join(data))
|
|
129
|
+
if event_name is not None and (not isinstance(event, dict) or event.get('type') != event_name):
|
|
130
|
+
raise ValueError('SSE event name mismatch')
|
|
131
|
+
state.push(event)
|
|
132
|
+
elif event_name is not None:
|
|
133
|
+
raise ValueError('SSE event missing data')
|
|
134
|
+
data, event_name = [], None
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
while True:
|
|
138
|
+
check_cancelled()
|
|
139
|
+
remaining = deadline - time.monotonic()
|
|
140
|
+
if remaining <= 0:
|
|
141
|
+
raise TimeoutError('stream deadline')
|
|
142
|
+
# Reset the socket timeout to the remaining invocation budget before each read.
|
|
143
|
+
set_response_timeout(response, remaining)
|
|
144
|
+
raw = response.readline()
|
|
145
|
+
if not raw:
|
|
146
|
+
if data or event_name is not None:
|
|
147
|
+
raise ValueError('unterminated SSE event')
|
|
148
|
+
break
|
|
149
|
+
line = raw.decode('utf-8', errors='strict').rstrip('\r\n')
|
|
150
|
+
if not line:
|
|
151
|
+
flush()
|
|
152
|
+
elif line.startswith(':'):
|
|
153
|
+
continue
|
|
154
|
+
else:
|
|
155
|
+
field, separator, value = line.partition(':')
|
|
156
|
+
if value.startswith(' '):
|
|
157
|
+
value = value[1:]
|
|
158
|
+
if field == 'event':
|
|
159
|
+
if event_name is not None:
|
|
160
|
+
raise ValueError('duplicate SSE event name')
|
|
161
|
+
event_name = value
|
|
162
|
+
elif field == 'data':
|
|
163
|
+
data.append(value if separator else '')
|
|
164
|
+
if not state.stopped:
|
|
165
|
+
raise ValueError('missing message_stop')
|
|
166
|
+
turn = parse_message(state.message, streamed=True, http_status=status, headers=headers,
|
|
167
|
+
usage_conflict=state.usage.conflict)
|
|
168
|
+
if not isinstance(turn, Result):
|
|
169
|
+
turn.previewed = True
|
|
170
|
+
return turn
|
|
171
|
+
except (UnicodeError, ValueError, TypeError, RecursionError) as exc:
|
|
172
|
+
# Size-limit exceptions are host guards, not malformed provider messages.
|
|
173
|
+
if isinstance(exc, LimitExceeded):
|
|
174
|
+
raise
|
|
175
|
+
message = state.message or {}
|
|
176
|
+
return Result(1, '', 'Malformed or unsupported Anthropic stream', metadata={
|
|
177
|
+
'provider': 'anthropic', 'provider_error_code': 'malformed_response', 'streamed': True,
|
|
178
|
+
'http_status': status, 'raw_response_metadata': {
|
|
179
|
+
k: message[k] for k in ('id', 'model', 'usage', 'stop_reason') if k in message}})
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Anthropic token evidence, with cache-write costs explicitly unsupported."""
|
|
2
|
+
from .accounting import _quantity, normalize_usage
|
|
3
|
+
|
|
4
|
+
TOKEN_FIELDS = ('input_tokens', 'output_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens')
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _zero_details(value):
|
|
8
|
+
if value is None:
|
|
9
|
+
return True
|
|
10
|
+
if type(value) is dict:
|
|
11
|
+
return all(_zero_details(v) for v in value.values())
|
|
12
|
+
return type(value) is int and value == 0
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def normalize_anthropic_usage(raw):
|
|
16
|
+
result = normalize_usage(None)
|
|
17
|
+
if raw is None:
|
|
18
|
+
return result
|
|
19
|
+
if type(raw) is not dict:
|
|
20
|
+
return {**result, 'status': 'untrusted'}
|
|
21
|
+
for key in TOKEN_FIELDS:
|
|
22
|
+
if key in raw and not _quantity(raw[key]):
|
|
23
|
+
return {**result, 'status': 'untrusted'}
|
|
24
|
+
result['output_tokens'] = raw.get('output_tokens')
|
|
25
|
+
result['cached_input_tokens'] = raw.get('cache_read_input_tokens')
|
|
26
|
+
if 'input_tokens' in raw:
|
|
27
|
+
inp = sum(raw.get(k, 0) for k in ('input_tokens', 'cache_read_input_tokens', 'cache_creation_input_tokens'))
|
|
28
|
+
if not _quantity(inp):
|
|
29
|
+
return {**result, 'status': 'untrusted'}
|
|
30
|
+
result['input_tokens'] = inp
|
|
31
|
+
if result['input_tokens'] is not None and result['output_tokens'] is not None:
|
|
32
|
+
total = result['input_tokens'] + result['output_tokens']
|
|
33
|
+
if not _quantity(total):
|
|
34
|
+
return {**result, 'status': 'untrusted'}
|
|
35
|
+
result.update(total_tokens=total, status='reported')
|
|
36
|
+
# Cache-write pricing cannot be represented by the existing rate-card contract.
|
|
37
|
+
unsupported = bool(raw.get('cache_creation_input_tokens', 0))
|
|
38
|
+
if raw.get('service_tier') not in (None, 'standard') or raw.get('inference_geo') not in (None, 'global'):
|
|
39
|
+
unsupported = True
|
|
40
|
+
for key, value in raw.items():
|
|
41
|
+
if key not in TOKEN_FIELDS and key not in {'service_tier', 'inference_geo'} and not _zero_details(value):
|
|
42
|
+
unsupported = True
|
|
43
|
+
if unsupported:
|
|
44
|
+
result['status'] = 'unsupported'
|
|
45
|
+
return result
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class UsageSnapshots:
|
|
49
|
+
"""Merge cumulative event snapshots; decreases invalidate accounting evidence."""
|
|
50
|
+
def __init__(self):
|
|
51
|
+
self.raw = None
|
|
52
|
+
self.conflict = False
|
|
53
|
+
|
|
54
|
+
def update(self, value):
|
|
55
|
+
if value is None:
|
|
56
|
+
return
|
|
57
|
+
if type(value) is not dict:
|
|
58
|
+
self.conflict = True
|
|
59
|
+
return
|
|
60
|
+
previous = self.raw or {}
|
|
61
|
+
if _snapshot_conflict(previous, value):
|
|
62
|
+
self.conflict = True
|
|
63
|
+
for key in TOKEN_FIELDS:
|
|
64
|
+
if key in value:
|
|
65
|
+
new, old = value[key], previous.get(key)
|
|
66
|
+
if not _quantity(new) or (old is not None and (not _quantity(old) or new < old)):
|
|
67
|
+
self.conflict = True
|
|
68
|
+
self.raw = _merge_snapshots(previous, value)
|
|
69
|
+
|
|
70
|
+
def normalized(self):
|
|
71
|
+
usage = normalize_anthropic_usage(self.raw)
|
|
72
|
+
return {**usage, 'status': 'untrusted'} if self.conflict else usage
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _snapshot_conflict(previous, current):
|
|
76
|
+
for key in previous.keys() & current.keys():
|
|
77
|
+
old, new = previous[key], current[key]
|
|
78
|
+
if type(old) is dict and type(new) is dict:
|
|
79
|
+
if _snapshot_conflict(old, new):
|
|
80
|
+
return True
|
|
81
|
+
elif _quantity(old) and _quantity(new):
|
|
82
|
+
if new < old:
|
|
83
|
+
return True
|
|
84
|
+
elif old != new:
|
|
85
|
+
return True
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _merge_snapshots(previous, current):
|
|
90
|
+
merged = dict(previous)
|
|
91
|
+
for key, value in current.items():
|
|
92
|
+
if type(value) is dict and type(previous.get(key)) is dict:
|
|
93
|
+
merged[key] = _merge_snapshots(previous[key], value)
|
|
94
|
+
else:
|
|
95
|
+
merged[key] = value
|
|
96
|
+
return merged
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Use Prosaic's inspection contract as the sole agent-definition parser."""
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class ProsaicArtifact:
|
|
12
|
+
id: str
|
|
13
|
+
type: str
|
|
14
|
+
frontmatter: dict
|
|
15
|
+
body: str
|
|
16
|
+
resources: tuple[dict, ...] = ()
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def from_inspection(cls, data: Mapping):
|
|
20
|
+
if data.get("type") not in {"subagent", "command"}:
|
|
21
|
+
raise ValueError("execution requires a Prosaic subagent or command artifact")
|
|
22
|
+
if not isinstance(data.get("id"), str) or not data["id"]:
|
|
23
|
+
raise ValueError("inspection artifact id is required")
|
|
24
|
+
if not isinstance(data.get("frontmatter"), dict) or not isinstance(data.get("body"), str):
|
|
25
|
+
raise ValueError("invalid Prosaic inspection output")
|
|
26
|
+
resources = data.get("resources", [])
|
|
27
|
+
if not isinstance(resources, list):
|
|
28
|
+
raise ValueError("resources must be a list")
|
|
29
|
+
for item in resources:
|
|
30
|
+
if not isinstance(item, dict) or not isinstance(item.get("relPath"), str) or not isinstance(item.get("content"), str):
|
|
31
|
+
raise ValueError("invalid bundled resource")
|
|
32
|
+
path = Path(item["relPath"])
|
|
33
|
+
if path.is_absolute() or ".." in path.parts:
|
|
34
|
+
raise ValueError("resource path escapes bundle")
|
|
35
|
+
return cls(data["id"], data["type"], dict(data["frontmatter"]), data["body"], tuple(resources))
|
|
36
|
+
|
|
37
|
+
def render(self, arguments: str = "") -> str:
|
|
38
|
+
body = self.body.replace("{{args}}", arguments) if "{{args}}" in self.body else self.body + ("\n\n## Arguments\n" + arguments if arguments else "")
|
|
39
|
+
for resource in self.resources:
|
|
40
|
+
body += f"\n\n## Bundled resource: {resource['relPath']}\n{resource['content']}"
|
|
41
|
+
return body
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def digest(self):
|
|
45
|
+
raw = json.dumps({"id": self.id, "type": self.type, "frontmatter": self.frontmatter, "body": self.body, "resources": self.resources}, sort_keys=True).encode()
|
|
46
|
+
return hashlib.sha256(raw).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def inspect_artifact(artifact_id: str, source: Path, *, executable="prosaic", timeout_s=30) -> ProsaicArtifact:
|
|
50
|
+
result = subprocess.run(
|
|
51
|
+
[executable, "inspect", artifact_id, "--source", str(source.resolve())],
|
|
52
|
+
cwd=source.resolve().parent, capture_output=True, text=True, timeout=timeout_s, check=False,
|
|
53
|
+
)
|
|
54
|
+
if result.returncode:
|
|
55
|
+
raise ValueError(f"Prosaic inspection failed: {result.stderr.strip()}")
|
|
56
|
+
return ProsaicArtifact.from_inspection(json.loads(result.stdout))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Invocation-local allowances; counts are dispatch attempts, not estimates."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class InvocationBudgetExceeded(Exception):
|
|
5
|
+
REASONS = frozenset({'provider_request_limit', 'tool_call_limit', 'token_limit', 'usage_unknown'})
|
|
6
|
+
|
|
7
|
+
def __init__(self, reason):
|
|
8
|
+
if reason not in self.REASONS:
|
|
9
|
+
raise ValueError('unknown invocation budget reason')
|
|
10
|
+
self.reason = reason
|
|
11
|
+
super().__init__(reason)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InvocationBudget:
|
|
15
|
+
def __init__(self, policy):
|
|
16
|
+
self.policy = policy
|
|
17
|
+
self.provider_requests = self.tool_calls = self.reported_tokens = 0
|
|
18
|
+
self.usage_complete = True
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def enabled(self):
|
|
22
|
+
return any(value is not None for value in (self.policy.max_provider_requests,
|
|
23
|
+
self.policy.max_tool_calls, self.policy.max_reported_tokens))
|
|
24
|
+
|
|
25
|
+
def metadata(self):
|
|
26
|
+
return {'provider_requests': self.provider_requests, 'tool_calls': self.tool_calls,
|
|
27
|
+
'reported_tokens': self.reported_tokens, 'usage_complete': self.usage_complete}
|
|
28
|
+
|
|
29
|
+
def _check_reported_allowance(self):
|
|
30
|
+
self.validate_terminal_usage()
|
|
31
|
+
if (self.policy.max_reported_tokens is not None and
|
|
32
|
+
self.reported_tokens >= self.policy.max_reported_tokens):
|
|
33
|
+
raise InvocationBudgetExceeded('token_limit')
|
|
34
|
+
|
|
35
|
+
def _reserve(self, name, cap, reason):
|
|
36
|
+
if cap is not None and getattr(self, name) >= cap:
|
|
37
|
+
raise InvocationBudgetExceeded(reason)
|
|
38
|
+
setattr(self, name, getattr(self, name) + 1)
|
|
39
|
+
|
|
40
|
+
def _check_provider(self):
|
|
41
|
+
self._check_reported_allowance()
|
|
42
|
+
cap = self.policy.max_provider_requests
|
|
43
|
+
if cap is not None and self.provider_requests >= cap:
|
|
44
|
+
raise InvocationBudgetExceeded('provider_request_limit')
|
|
45
|
+
|
|
46
|
+
def before_provider(self):
|
|
47
|
+
self._check_provider()
|
|
48
|
+
self._reserve('provider_requests', self.policy.max_provider_requests, 'provider_request_limit')
|
|
49
|
+
|
|
50
|
+
def before_tool(self):
|
|
51
|
+
self._check_reported_allowance()
|
|
52
|
+
self._reserve('tool_calls', self.policy.max_tool_calls, 'tool_call_limit')
|
|
53
|
+
|
|
54
|
+
def record_usage(self, usage):
|
|
55
|
+
if type(usage) is not int or usage < 0:
|
|
56
|
+
self.usage_complete = False
|
|
57
|
+
else:
|
|
58
|
+
self.reported_tokens += usage
|
|
59
|
+
self.validate_terminal_usage()
|
|
60
|
+
|
|
61
|
+
def validate_terminal_usage(self):
|
|
62
|
+
cap = self.policy.max_reported_tokens
|
|
63
|
+
if cap is not None:
|
|
64
|
+
if not self.usage_complete:
|
|
65
|
+
raise InvocationBudgetExceeded('usage_unknown')
|
|
66
|
+
if self.reported_tokens > cap:
|
|
67
|
+
raise InvocationBudgetExceeded('token_limit')
|
prosaic_runtime/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Small CLI; stdout is reserved for the requested machine output."""
|
|
2
|
+
import argparse
|
|
3
|
+
from dataclasses import asdict
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from .runtime import ProsaicRuntime
|
|
9
|
+
from .policy import RunPolicy
|
|
10
|
+
from .config import RuntimeConfig
|
|
11
|
+
from .console import Progress
|
|
12
|
+
from .diagnostics import doctor, smoke, failure_hint, conformance
|
|
13
|
+
from .accounting import ExecutionContext, RateCard
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv=None):
|
|
17
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
18
|
+
mode = argv.pop(0) if argv and argv[0] in {"doctor", "smoke", "preflight", "conformance"} else "run"
|
|
19
|
+
parser = argparse.ArgumentParser(prog="prosaic-runtime")
|
|
20
|
+
if mode == "run":
|
|
21
|
+
parser.add_argument("artifact", help="Prosaic artifact identifier (also: doctor or smoke subcommands)")
|
|
22
|
+
if mode == 'preflight':
|
|
23
|
+
target = parser.add_mutually_exclusive_group(required=True)
|
|
24
|
+
target.add_argument('--agent', help='Prosaic artifact identifier to check without inference')
|
|
25
|
+
target.add_argument('--all-tools', action='store_true', help='check every registered/configured tool')
|
|
26
|
+
if mode == 'run':
|
|
27
|
+
parser.add_argument('--initial-tool', help='require this granted tool as the first native call')
|
|
28
|
+
for name in ('application_id', 'tenant_id', 'billing_account_id', 'actor_id', 'project_id', 'request_id', 'run_id', 'invocation_id', 'parent_invocation_id'):
|
|
29
|
+
parser.add_argument('--' + name.replace('_', '-'), help='trusted execution attribution; omitted IDs use defaults')
|
|
30
|
+
parser.add_argument('--accounting-dsn-env', help='environment variable holding the accounting PostgreSQL DSN; requires separately installed adapter')
|
|
31
|
+
parser.add_argument('--accounting-namespace', help='persistent deployment namespace')
|
|
32
|
+
parser.add_argument('--accounting-environment', help='isolated environment, e.g. production or sandbox')
|
|
33
|
+
parser.add_argument('--accounting-rate-card', help='explicit JSON rate card for internal estimates')
|
|
34
|
+
parser.add_argument('--accounting-provider', choices=('openai-compatible', 'anthropic'),
|
|
35
|
+
help='accounting provider identity; defaults to openai-compatible and must match the selected endpoint')
|
|
36
|
+
parser.add_argument("--source", default=".prosaic")
|
|
37
|
+
parser.add_argument("--config", help="YAML configuration (default: prosaic-runtime.yaml, then prosaic-runtime.yml)")
|
|
38
|
+
parser.add_argument("--arguments", default="")
|
|
39
|
+
parser.add_argument("--cwd", default=".")
|
|
40
|
+
parser.add_argument("--allow-tool", action="append", default=[])
|
|
41
|
+
parser.add_argument("--read-root", action="append", default=[])
|
|
42
|
+
parser.add_argument("--write-path", action="append", default=[])
|
|
43
|
+
parser.add_argument("--forbid-root", action="append", default=[])
|
|
44
|
+
parser.add_argument("--timeout", type=float, help="override YAML limits.timeout_s")
|
|
45
|
+
parser.add_argument("--max-tool-rounds", type=int, help="override YAML limits.max_tool_rounds")
|
|
46
|
+
output = parser.add_mutually_exclusive_group()
|
|
47
|
+
output.add_argument("--events", action="store_true", help="emit JSONL events followed by result")
|
|
48
|
+
output.add_argument("--output", choices=("json", "text"), default="json")
|
|
49
|
+
parser.add_argument("--quiet", action="store_true", help="suppress stderr progress (errors remain visible)")
|
|
50
|
+
if mode != "run":
|
|
51
|
+
parser.add_argument("--profile", help="endpoint profile; defaults to default_profile")
|
|
52
|
+
if mode == "doctor":
|
|
53
|
+
parser.add_argument("--inference", action="store_true", help="opt in to one model request (may incur charges)")
|
|
54
|
+
if mode == "smoke":
|
|
55
|
+
parser.add_argument("--live", action="store_true", required=True, help="explicitly authorize live model calls")
|
|
56
|
+
if mode == 'conformance':
|
|
57
|
+
parser.add_argument('--live', action='store_true', help='explicitly authorize bounded provider calls')
|
|
58
|
+
args = parser.parse_args(argv)
|
|
59
|
+
def emit(event):
|
|
60
|
+
print(json.dumps(event), flush=True)
|
|
61
|
+
try:
|
|
62
|
+
config = RuntimeConfig.load(args.config)
|
|
63
|
+
policy = RunPolicy(frozenset(args.allow_tool), tuple(args.read_root), tuple(args.write_path),
|
|
64
|
+
tuple(args.forbid_root),
|
|
65
|
+
args.timeout if args.timeout is not None else config.limits.timeout_s,
|
|
66
|
+
args.max_tool_rounds if args.max_tool_rounds is not None else config.limits.max_tool_rounds,
|
|
67
|
+
initial_tool=getattr(args, 'initial_tool', None))
|
|
68
|
+
with Progress(args.quiet or args.events) as progress:
|
|
69
|
+
sink = emit if args.events else progress
|
|
70
|
+
if mode == 'preflight':
|
|
71
|
+
runtime = ProsaicRuntime(config, source=args.source)
|
|
72
|
+
report = runtime.preflight(args.agent, all_tools=args.all_tools, cwd=args.cwd, policy=policy)
|
|
73
|
+
if args.output == 'text':
|
|
74
|
+
print(f"preflight: {'passed' if report['ok'] else 'failed'}")
|
|
75
|
+
for name, check in report['checks'].items():
|
|
76
|
+
print(f"{name}: {check['status']} — {check['message']}")
|
|
77
|
+
else:
|
|
78
|
+
emit(report)
|
|
79
|
+
return 0 if report['ok'] else 1
|
|
80
|
+
if mode != "run":
|
|
81
|
+
profile = args.profile or config.default_profile
|
|
82
|
+
if profile not in config.profiles:
|
|
83
|
+
raise ValueError(f"Unknown endpoint profile: {profile}")
|
|
84
|
+
if mode == 'conformance':
|
|
85
|
+
suite_policy = RunPolicy(timeout_s=args.timeout if args.timeout is not None else 60,
|
|
86
|
+
max_tool_rounds=args.max_tool_rounds if args.max_tool_rounds is not None else 2,
|
|
87
|
+
max_input_bytes=65536, max_provider_requests=12, max_tool_calls=4,
|
|
88
|
+
max_reported_tokens=32768)
|
|
89
|
+
report = conformance(config, profile, live=args.live, policy=suite_policy,
|
|
90
|
+
observer=emit if args.events else None)
|
|
91
|
+
if args.output == 'text':
|
|
92
|
+
print('conformance: ' + report['qualification'])
|
|
93
|
+
for case in report['cases']:
|
|
94
|
+
print(f"{case['id']}: {case['state']} — {case['reason']}")
|
|
95
|
+
else:
|
|
96
|
+
emit(report)
|
|
97
|
+
return 0 if not args.live or report['qualification'] == 'qualified' else 1
|
|
98
|
+
if mode == "doctor":
|
|
99
|
+
report = doctor(config, profile, inference=args.inference, timeout_s=policy.timeout_s, on_event=sink)
|
|
100
|
+
else:
|
|
101
|
+
report = smoke(config, profile, timeout_s=policy.timeout_s,
|
|
102
|
+
max_tool_rounds=policy.max_tool_rounds, on_event=sink)
|
|
103
|
+
if args.output == "text":
|
|
104
|
+
print(f"{mode}: {'passed' if report['ok'] else 'failed'}")
|
|
105
|
+
for name, check in report.get("checks", report.get("tests", {})).items():
|
|
106
|
+
print(f"{name}: {check['status']}" + (f" — {check['message']}" if check.get("message") else ""))
|
|
107
|
+
else:
|
|
108
|
+
emit(report)
|
|
109
|
+
return 0 if report["ok"] else 1
|
|
110
|
+
recorder = None
|
|
111
|
+
if args.accounting_dsn_env:
|
|
112
|
+
if not args.accounting_namespace or not args.accounting_environment:
|
|
113
|
+
raise ValueError('accounting requires explicit namespace and environment')
|
|
114
|
+
dsn = os.environ.get(args.accounting_dsn_env)
|
|
115
|
+
if not dsn:
|
|
116
|
+
raise ValueError('accounting DSN environment variable is not set')
|
|
117
|
+
try:
|
|
118
|
+
from prosaic_runtime_postgres import PostgresRecorder
|
|
119
|
+
except ImportError as exc:
|
|
120
|
+
raise ValueError('install b3-prosaic-runtime-postgres to enable durable accounting') from exc
|
|
121
|
+
rate_card = None
|
|
122
|
+
if args.accounting_rate_card:
|
|
123
|
+
with open(args.accounting_rate_card, encoding='utf-8') as handle:
|
|
124
|
+
rate_card = RateCard(**json.load(handle))
|
|
125
|
+
recorder = PostgresRecorder(dsn, namespace=args.accounting_namespace,
|
|
126
|
+
environment=args.accounting_environment, rate_card=rate_card,
|
|
127
|
+
provider_id=args.accounting_provider or 'openai-compatible')
|
|
128
|
+
elif args.accounting_namespace or args.accounting_environment or args.accounting_rate_card or args.accounting_provider:
|
|
129
|
+
raise ValueError('accounting options require --accounting-dsn-env')
|
|
130
|
+
names = ('application_id', 'tenant_id', 'billing_account_id', 'actor_id', 'project_id', 'request_id', 'run_id', 'invocation_id', 'parent_invocation_id')
|
|
131
|
+
values = {name: getattr(args, name) for name in names if getattr(args, name) is not None}
|
|
132
|
+
runtime = ProsaicRuntime(config, source=args.source, accounting=recorder)
|
|
133
|
+
result = runtime.run(args.artifact, args.arguments, cwd=args.cwd, policy=policy, on_event=sink,
|
|
134
|
+
**({'context': ExecutionContext(**values)} if values else {}))
|
|
135
|
+
if args.output == "text":
|
|
136
|
+
if result.stdout:
|
|
137
|
+
print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
|
|
138
|
+
if result.exit_code:
|
|
139
|
+
print(failure_hint(result), file=sys.stderr)
|
|
140
|
+
return 0 if result.exit_code == 0 else 1
|
|
141
|
+
if result.exit_code:
|
|
142
|
+
print(failure_hint(result), file=sys.stderr)
|
|
143
|
+
emit({"event": "result", **asdict(result)})
|
|
144
|
+
return 0 if result.exit_code == 0 else 1
|
|
145
|
+
except KeyboardInterrupt:
|
|
146
|
+
return 130
|
|
147
|
+
except (ValueError, TypeError, KeyError, OSError, subprocess.TimeoutExpired) as exc:
|
|
148
|
+
print(json.dumps({"error": str(exc)}), file=sys.stderr)
|
|
149
|
+
return 2
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
if __name__ == "__main__":
|
|
153
|
+
raise SystemExit(main())
|