annoscript 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.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Python 3.10 job wire contracts. No transport or execution dependencies."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import math
|
|
9
|
+
from email.utils import parsedate_to_datetime
|
|
10
|
+
|
|
11
|
+
from annoscript._data import snapshot
|
|
12
|
+
from annoscript.remote import RemoteProtocolError, _json, ir_sha256
|
|
13
|
+
|
|
14
|
+
PROTOCOL_VERSION = 1
|
|
15
|
+
TERMINAL = frozenset(("completed", "cancelled", "failed"))
|
|
16
|
+
STATES = TERMINAL | {"queued", "running", "paused"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def encode(value, *, canonical=False):
|
|
20
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, sort_keys=canonical,
|
|
21
|
+
separators=(",", ":")).encode("utf-8")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def decode(value):
|
|
25
|
+
if isinstance(value, (bytes, bytearray)):
|
|
26
|
+
try:
|
|
27
|
+
value = value.decode("utf-8")
|
|
28
|
+
except UnicodeError:
|
|
29
|
+
raise RemoteProtocolError("JSON must be UTF-8") from None
|
|
30
|
+
return _json(value)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def nonempty(value):
|
|
34
|
+
return type(value) is str and bool(value.strip())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def integer(value, minimum=0):
|
|
38
|
+
return type(value) is int and value >= minimum
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def validate_request(value):
|
|
42
|
+
value = snapshot(value)
|
|
43
|
+
required = {"protocol_version", "ir", "ir_sha256", "mode", "inputs"}
|
|
44
|
+
optional = {"release_id", "application_revision", "session_id"}
|
|
45
|
+
if type(value) is not dict or not required <= value.keys() or value.keys() - required - optional:
|
|
46
|
+
raise ValueError("invalid job request fields")
|
|
47
|
+
if type(value["protocol_version"]) is not int or value["protocol_version"] != 1:
|
|
48
|
+
raise ValueError("unsupported job protocol version")
|
|
49
|
+
if type(value["ir"]) is not dict or ir_sha256(value["ir"]) != value["ir_sha256"]:
|
|
50
|
+
raise ValueError("IR digest mismatch")
|
|
51
|
+
if value["mode"] not in ("eager", "strict", "speculative"):
|
|
52
|
+
raise ValueError("invalid execution mode")
|
|
53
|
+
if type(value["inputs"]) is not list or not value["inputs"]:
|
|
54
|
+
raise ValueError("a job needs at least one input")
|
|
55
|
+
indices = set()
|
|
56
|
+
for item in value["inputs"]:
|
|
57
|
+
if (type(item) is not dict or set(item) != {"index", "state"}
|
|
58
|
+
or not integer(item["index"]) or item["index"] in indices):
|
|
59
|
+
raise ValueError("inputs require unique nonnegative indices and states")
|
|
60
|
+
snapshot(item["state"], state=True)
|
|
61
|
+
indices.add(item["index"])
|
|
62
|
+
for name in optional & value.keys():
|
|
63
|
+
if not nonempty(value[name]):
|
|
64
|
+
raise ValueError(f"{name} must be a nonempty string")
|
|
65
|
+
return value
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def request_sha256(request):
|
|
69
|
+
# Normalize protocol field order and IR encoding, but NEVER sort state keys:
|
|
70
|
+
# an IR program can iterate over the keys of an input object.
|
|
71
|
+
value = {key: request[key] for key in ("protocol_version", "ir_sha256", "mode")}
|
|
72
|
+
value["inputs"] = [{"index": item["index"], "state": item["state"]}
|
|
73
|
+
for item in request["inputs"]]
|
|
74
|
+
for key in ("release_id", "application_revision", "session_id"):
|
|
75
|
+
if key in request:
|
|
76
|
+
value[key] = request[key]
|
|
77
|
+
return hashlib.sha256(encode(value)).hexdigest()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def cursor_for(job_id, seq):
|
|
81
|
+
return base64.urlsafe_b64encode(encode([1, job_id, seq])).decode("ascii").rstrip("=")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def cursor_seq(cursor, job_id):
|
|
85
|
+
if cursor is None:
|
|
86
|
+
return 0
|
|
87
|
+
try:
|
|
88
|
+
raw = base64.b64decode(cursor + "=" * (-len(cursor) % 4), altchars=b"-_", validate=True)
|
|
89
|
+
value = decode(raw)
|
|
90
|
+
if (type(value) is not list or len(value) != 3 or type(value[0]) is not int
|
|
91
|
+
or value[0] != 1 or value[1] != job_id or not integer(value[2])
|
|
92
|
+
or cursor_for(job_id, value[2]) != cursor):
|
|
93
|
+
raise ValueError()
|
|
94
|
+
return value[2]
|
|
95
|
+
except (ValueError, TypeError, UnicodeError, RemoteProtocolError):
|
|
96
|
+
raise ValueError("invalid cursor for this job") from None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def retry_after_seconds(value, now):
|
|
100
|
+
if value is None:
|
|
101
|
+
return 0.0
|
|
102
|
+
try:
|
|
103
|
+
seconds = float(value)
|
|
104
|
+
if math.isfinite(seconds):
|
|
105
|
+
return max(0.0, seconds)
|
|
106
|
+
except (ValueError, TypeError):
|
|
107
|
+
pass
|
|
108
|
+
try:
|
|
109
|
+
return max(0.0, parsedate_to_datetime(value).timestamp() - now)
|
|
110
|
+
except (ValueError, TypeError, OverflowError):
|
|
111
|
+
return 0.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def validate_status(job):
|
|
115
|
+
from annoscript.remote import _error
|
|
116
|
+
fields = {"job_id", "ir_sha256", "release_id", "mode", "input_indices", "state", "counts",
|
|
117
|
+
"pause_generation", "pause_reason", "created_at", "updated_at"}
|
|
118
|
+
if type(job) is not dict or set(job) != fields:
|
|
119
|
+
raise RemoteProtocolError("invalid job status fields")
|
|
120
|
+
if (not all(nonempty(job[k]) for k in ("job_id", "ir_sha256", "release_id"))
|
|
121
|
+
or len(job["ir_sha256"]) != 64 or any(c not in "0123456789abcdef" for c in job["ir_sha256"])
|
|
122
|
+
or job["mode"] not in ("strict", "eager", "speculative")
|
|
123
|
+
or job["state"] not in STATES or not integer(job["pause_generation"])):
|
|
124
|
+
raise RemoteProtocolError("invalid job status")
|
|
125
|
+
indices, counts = job["input_indices"], job["counts"]
|
|
126
|
+
if (type(indices) is not list or not indices or any(not integer(i) for i in indices)
|
|
127
|
+
or len(set(indices)) != len(indices) or type(counts) is not dict
|
|
128
|
+
or set(counts) != {"total", "completed", "success", "error"}
|
|
129
|
+
or any(not integer(n) for n in counts.values())
|
|
130
|
+
or counts["total"] != len(indices)
|
|
131
|
+
or counts["completed"] != counts["success"] + counts["error"]
|
|
132
|
+
or counts["completed"] > counts["total"]
|
|
133
|
+
or ((job["state"] in TERMINAL) != (counts["completed"] == counts["total"]))):
|
|
134
|
+
raise RemoteProtocolError("invalid job indices or counts")
|
|
135
|
+
if job["pause_reason"] is not None:
|
|
136
|
+
_error(job["pause_reason"])
|
|
137
|
+
if (job["state"] == "paused") != (job["pause_reason"] is not None):
|
|
138
|
+
raise RemoteProtocolError("inconsistent paused job")
|
|
139
|
+
if any(type(job[k]) not in (int, float) or not math.isfinite(job[k])
|
|
140
|
+
for k in ("created_at", "updated_at")):
|
|
141
|
+
raise RemoteProtocolError("invalid job timestamps")
|
|
142
|
+
return job
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def validate_page(page, job_id, cursor=None):
|
|
146
|
+
from annoscript._client_common import Result
|
|
147
|
+
from annoscript.remote import _error
|
|
148
|
+
if (type(page) is not dict
|
|
149
|
+
or set(page) != {"protocol_version", "job", "results", "next_cursor", "has_more", "done"}
|
|
150
|
+
or type(page["protocol_version"]) is not int or page["protocol_version"] != 1
|
|
151
|
+
or type(page["results"]) is not list
|
|
152
|
+
or type(page["has_more"]) is not bool or type(page["done"]) is not bool):
|
|
153
|
+
raise RemoteProtocolError("invalid result page")
|
|
154
|
+
job = validate_status(page["job"])
|
|
155
|
+
if job["job_id"] != job_id:
|
|
156
|
+
raise RemoteProtocolError("page belongs to another job")
|
|
157
|
+
try:
|
|
158
|
+
last = cursor_seq(cursor, job_id)
|
|
159
|
+
next_seq = cursor_seq(page["next_cursor"], job_id)
|
|
160
|
+
except ValueError as exc:
|
|
161
|
+
raise RemoteProtocolError(str(exc)) from None
|
|
162
|
+
seen, expected = set(), set(job["input_indices"])
|
|
163
|
+
for item in page["results"]:
|
|
164
|
+
if (type(item) is not dict or set(item) != {"seq", "index", "status", "value", "error", "attempts"}
|
|
165
|
+
or not integer(item["seq"], 1) or item["seq"] != last + 1
|
|
166
|
+
or not integer(item["index"]) or item["index"] not in expected or item["index"] in seen
|
|
167
|
+
or not integer(item["attempts"])):
|
|
168
|
+
raise RemoteProtocolError("invalid, missing or duplicate page result")
|
|
169
|
+
try:
|
|
170
|
+
Result(item["index"], item["status"], item["value"],
|
|
171
|
+
None if item["error"] is None else _error(item["error"]))
|
|
172
|
+
except ValueError as exc:
|
|
173
|
+
raise RemoteProtocolError(str(exc)) from None
|
|
174
|
+
last = item["seq"]
|
|
175
|
+
seen.add(item["index"])
|
|
176
|
+
if (next_seq != last or last > job["counts"]["completed"]
|
|
177
|
+
or page["has_more"] != (last < job["counts"]["completed"])
|
|
178
|
+
or page["done"] != (job["state"] in TERMINAL and not page["has_more"])):
|
|
179
|
+
raise RemoteProtocolError("inconsistent result cursor or completion")
|
|
180
|
+
return page
|
annoscript/jobs.py
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Python 3.10+ durable job HTTP client. Closing it does not cancel durable jobs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import hashlib
|
|
7
|
+
import random
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from urllib.parse import quote
|
|
11
|
+
|
|
12
|
+
from annoscript.job_protocol import (decode, encode, nonempty, retry_after_seconds,
|
|
13
|
+
validate_page, validate_request, validate_status)
|
|
14
|
+
from annoscript.remote import (RemoteProtocolError, RemoteSubmissionError,
|
|
15
|
+
RemoteTransportError, ir_sha256, _error)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RemoteJobPausedError(RemoteSubmissionError):
|
|
19
|
+
def __init__(self, job):
|
|
20
|
+
reason = job["pause_reason"]
|
|
21
|
+
super().__init__(reason["code"], reason["message"])
|
|
22
|
+
self.job = job
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RemoteCancellationError(RemoteTransportError):
|
|
26
|
+
"""Cancellation was requested but could not be confirmed before closing."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def job_id(job):
|
|
30
|
+
value = job.get("job_id") if type(job) is dict else job
|
|
31
|
+
if not nonempty(value):
|
|
32
|
+
raise ValueError("job must be a job status or nonempty job ID")
|
|
33
|
+
return value
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AnnoJobClient:
|
|
37
|
+
def __init__(self, base_url, auth_token=None, *, timeout=30.0, transport=None,
|
|
38
|
+
_clock=time.monotonic, _wall_clock=time.time, _sleep=time.sleep,
|
|
39
|
+
_random=random.random):
|
|
40
|
+
import httpx
|
|
41
|
+
url = httpx.URL(base_url)
|
|
42
|
+
if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.query or url.fragment:
|
|
43
|
+
raise ValueError("base_url must be an HTTP(S) URL without credentials, query or fragment")
|
|
44
|
+
if auth_token is not None and (not nonempty(auth_token) or "\r" in auth_token or "\n" in auth_token):
|
|
45
|
+
raise ValueError("auth_token must be a nonempty single-line string")
|
|
46
|
+
if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
|
|
47
|
+
raise ValueError("timeout must be finite and positive")
|
|
48
|
+
self.base_url, self.timeout = str(url).rstrip("/"), timeout
|
|
49
|
+
self.clock, self.wall_clock, self.sleep, self.random = _clock, _wall_clock, _sleep, _random
|
|
50
|
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
|
51
|
+
if auth_token is not None:
|
|
52
|
+
headers["Authorization"] = auth_token if auth_token.lower().startswith("bearer ") else "Bearer " + auth_token
|
|
53
|
+
self._http = httpx.Client(headers=headers, timeout=timeout, follow_redirects=False,
|
|
54
|
+
transport=transport if transport is not None else httpx.HTTPTransport(retries=0))
|
|
55
|
+
self.closed = False
|
|
56
|
+
|
|
57
|
+
def __enter__(self):
|
|
58
|
+
if self.closed:
|
|
59
|
+
raise RuntimeError("client is closed")
|
|
60
|
+
return self
|
|
61
|
+
|
|
62
|
+
def __exit__(self, *args):
|
|
63
|
+
self.close()
|
|
64
|
+
|
|
65
|
+
def close(self):
|
|
66
|
+
if not self.closed:
|
|
67
|
+
self.closed = True
|
|
68
|
+
self._http.close()
|
|
69
|
+
|
|
70
|
+
def _request(self, method, path, *, payload=None, key=None, params=None,
|
|
71
|
+
budget=120.0, attempts=5):
|
|
72
|
+
# httpx timeouts are per network phase; DNS or a slowly progressing
|
|
73
|
+
# response must not turn a bounded reconnect budget into an endless wait.
|
|
74
|
+
# A late request can still reach the server, so all mutations are either
|
|
75
|
+
# idempotent or guarded by a closed ownership session.
|
|
76
|
+
outcome, done = [], threading.Event()
|
|
77
|
+
def run():
|
|
78
|
+
try:
|
|
79
|
+
outcome.append((True, self._request_with_retries(method, path, payload=payload,
|
|
80
|
+
key=key, params=params, budget=budget, attempts=attempts)))
|
|
81
|
+
except BaseException as exc:
|
|
82
|
+
outcome.append((False, exc))
|
|
83
|
+
finally:
|
|
84
|
+
done.set()
|
|
85
|
+
threading.Thread(target=run, name="anno-job-http", daemon=True).start()
|
|
86
|
+
if not done.wait(max(0.0, budget)):
|
|
87
|
+
raise RemoteTransportError("remote reconnect deadline exceeded")
|
|
88
|
+
success, value = outcome[0]
|
|
89
|
+
if not success:
|
|
90
|
+
raise value
|
|
91
|
+
return value
|
|
92
|
+
|
|
93
|
+
def _request_with_retries(self, method, path, *, payload=None, key=None, params=None,
|
|
94
|
+
budget=120.0, attempts=5):
|
|
95
|
+
import httpx
|
|
96
|
+
if self.closed:
|
|
97
|
+
raise RuntimeError("client is closed")
|
|
98
|
+
if key is not None and (not nonempty(key) or len(key) > 256 or "\r" in key or "\n" in key):
|
|
99
|
+
raise ValueError("invalid idempotency key")
|
|
100
|
+
content = encode(payload) if payload is not None else None
|
|
101
|
+
deadline = self.clock() + budget
|
|
102
|
+
for attempt in range(attempts):
|
|
103
|
+
remaining = deadline - self.clock()
|
|
104
|
+
if remaining <= 0:
|
|
105
|
+
raise RemoteTransportError("remote reconnect budget exhausted")
|
|
106
|
+
retry_after = None
|
|
107
|
+
try:
|
|
108
|
+
with self._http.stream(method, self.base_url + path, content=content, params=params,
|
|
109
|
+
headers={"Idempotency-Key": key} if key is not None else None,
|
|
110
|
+
timeout=min(self.timeout, remaining)) as response:
|
|
111
|
+
chunks = []
|
|
112
|
+
for chunk in response.iter_bytes():
|
|
113
|
+
if self.clock() > deadline:
|
|
114
|
+
raise httpx.ReadTimeout("request deadline exceeded")
|
|
115
|
+
chunks.append(chunk)
|
|
116
|
+
raw = b"".join(chunks)
|
|
117
|
+
if not 200 <= response.status_code < 300:
|
|
118
|
+
code, message = "http_error", f"remote HTTP error {response.status_code}"
|
|
119
|
+
try:
|
|
120
|
+
error = _error(decode(raw)["error"])
|
|
121
|
+
code, message = error.code, error.message
|
|
122
|
+
except (RemoteProtocolError, KeyError, TypeError):
|
|
123
|
+
pass
|
|
124
|
+
error = RemoteSubmissionError(code, message, http_status=response.status_code,
|
|
125
|
+
retry_after=response.headers.get("Retry-After"))
|
|
126
|
+
if response.status_code not in (408, 429) and not 500 <= response.status_code <= 599:
|
|
127
|
+
raise error
|
|
128
|
+
failure, retry_after = error, error.retry_after
|
|
129
|
+
else:
|
|
130
|
+
if response.headers.get("Content-Type", "").split(";", 1)[0].lower() != "application/json":
|
|
131
|
+
raise RemoteProtocolError("expected an application/json response")
|
|
132
|
+
value = decode(raw)
|
|
133
|
+
if type(value) is not dict or type(value.get("protocol_version")) is not int or value["protocol_version"] != 1:
|
|
134
|
+
raise RemoteProtocolError("unsupported job protocol version")
|
|
135
|
+
return value
|
|
136
|
+
except httpx.RequestError as exc:
|
|
137
|
+
failure = RemoteTransportError("remote HTTP request interrupted")
|
|
138
|
+
failure.__cause__ = exc
|
|
139
|
+
if attempt + 1 == attempts:
|
|
140
|
+
raise failure
|
|
141
|
+
delay = max(self.random() * min(10.0, 0.5 * 2 ** attempt),
|
|
142
|
+
retry_after_seconds(retry_after, self.wall_clock()))
|
|
143
|
+
if self.clock() + delay >= deadline:
|
|
144
|
+
raise failure
|
|
145
|
+
self.sleep(delay)
|
|
146
|
+
raise RemoteTransportError("remote reconnect budget exhausted")
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _status(response, expected_id=None):
|
|
150
|
+
if set(response) != {"protocol_version", "job"}:
|
|
151
|
+
raise RemoteProtocolError("invalid job envelope")
|
|
152
|
+
job = validate_status(response["job"])
|
|
153
|
+
if expected_id is not None and job["job_id"] != expected_id:
|
|
154
|
+
raise RemoteProtocolError("response belongs to another job")
|
|
155
|
+
return job
|
|
156
|
+
|
|
157
|
+
@staticmethod
|
|
158
|
+
def _binding(expected, actual):
|
|
159
|
+
if type(expected) is dict:
|
|
160
|
+
for field in ("job_id", "ir_sha256", "release_id", "mode", "input_indices"):
|
|
161
|
+
if expected[field] != actual[field]:
|
|
162
|
+
raise RemoteProtocolError("immutable job binding changed")
|
|
163
|
+
return actual
|
|
164
|
+
|
|
165
|
+
def submit_ir(self, ir, inputs, *, idempotency_key, mode="eager", release_id=None,
|
|
166
|
+
application_revision=None, session_id=None):
|
|
167
|
+
ir = ir.to_dict() if hasattr(ir, "to_dict") else ir
|
|
168
|
+
payload = {"protocol_version": 1, "ir": ir, "ir_sha256": ir_sha256(ir), "mode": mode, "inputs": inputs}
|
|
169
|
+
for name, value in (("release_id", release_id), ("application_revision", application_revision), ("session_id", session_id)):
|
|
170
|
+
if value is not None:
|
|
171
|
+
payload[name] = value
|
|
172
|
+
payload = validate_request(payload)
|
|
173
|
+
job = self._status(self._request("POST", "/v1/jobs", payload=payload, key=idempotency_key))
|
|
174
|
+
if (job["ir_sha256"] != payload["ir_sha256"] or job["mode"] != mode
|
|
175
|
+
or job["input_indices"] != [item["index"] for item in payload["inputs"]]
|
|
176
|
+
or (release_id is not None and job["release_id"] != release_id)):
|
|
177
|
+
raise RemoteProtocolError("accepted job does not match submitted IR, inputs or release")
|
|
178
|
+
return job
|
|
179
|
+
|
|
180
|
+
def lookup(self, idempotency_key, **options):
|
|
181
|
+
return self._status(self._request("GET", "/v1/jobs:lookup", key=idempotency_key, **options))
|
|
182
|
+
|
|
183
|
+
def release(self, release_id):
|
|
184
|
+
"""Read an immutable release without importing server implementation."""
|
|
185
|
+
if not nonempty(release_id):
|
|
186
|
+
raise ValueError("release_id must be nonempty")
|
|
187
|
+
response = self._request("GET", f"/v1/releases/{quote(release_id, safe='')}")
|
|
188
|
+
if (set(response) != {"protocol_version", "release_id", "manifest", "available"}
|
|
189
|
+
or response["release_id"] != release_id or type(response["available"]) is not bool
|
|
190
|
+
or type(response["manifest"]) is not dict
|
|
191
|
+
or hashlib.sha256(encode(response["manifest"], canonical=True)).hexdigest() != release_id):
|
|
192
|
+
raise RemoteProtocolError("invalid release manifest or digest")
|
|
193
|
+
return response
|
|
194
|
+
|
|
195
|
+
def status(self, job):
|
|
196
|
+
jid = job_id(job)
|
|
197
|
+
return self._binding(job, self._status(self._request("GET", f"/v1/jobs/{quote(jid, safe='')}"), jid))
|
|
198
|
+
|
|
199
|
+
def fetch_results(self, job, *, cursor=None, limit=100):
|
|
200
|
+
jid = job_id(job)
|
|
201
|
+
params = {"limit": limit}
|
|
202
|
+
if cursor is not None:
|
|
203
|
+
params["cursor"] = cursor
|
|
204
|
+
page = validate_page(self._request("GET", f"/v1/jobs/{quote(jid, safe='')}/results", params=params), jid, cursor)
|
|
205
|
+
self._binding(job, page["job"])
|
|
206
|
+
return page
|
|
207
|
+
|
|
208
|
+
def metrics(self, job):
|
|
209
|
+
"""Read a consistent metric snapshot without loading server modules."""
|
|
210
|
+
from annoscript.metrics import validate_metrics
|
|
211
|
+
jid = job_id(job)
|
|
212
|
+
value = validate_metrics(self._request("GET", f"/v1/jobs/{quote(jid, safe='')}/metrics"), jid)
|
|
213
|
+
self._binding(job, value["job"])
|
|
214
|
+
return value
|
|
215
|
+
|
|
216
|
+
def resume(self, job, *, pause_generation, idempotency_key):
|
|
217
|
+
jid = job_id(job)
|
|
218
|
+
return self._binding(job, self._status(self._request("POST", f"/v1/jobs/{quote(jid, safe='')}:resume",
|
|
219
|
+
payload={"pause_generation": pause_generation}, key=idempotency_key), jid))
|
|
220
|
+
|
|
221
|
+
def cancel(self, job, **options):
|
|
222
|
+
jid = job_id(job)
|
|
223
|
+
result = self._status(self._request("POST", f"/v1/jobs/{quote(jid, safe='')}:cancel", payload={}, **options), jid)
|
|
224
|
+
self._binding(job, result)
|
|
225
|
+
if result["state"] not in ("completed", "cancelled", "failed"):
|
|
226
|
+
raise RemoteProtocolError("cancellation was not persisted")
|
|
227
|
+
return result
|
|
228
|
+
|
|
229
|
+
def _session(self, response):
|
|
230
|
+
if (set(response) != {"protocol_version", "session_id", "state", "expires_at", "heartbeat_interval"}
|
|
231
|
+
or not nonempty(response["session_id"]) or response["state"] not in ("active", "closed", "expired")
|
|
232
|
+
or any(type(response[k]) not in (int, float) or not math.isfinite(response[k]) or response[k] <= 0
|
|
233
|
+
for k in ("expires_at", "heartbeat_interval"))):
|
|
234
|
+
raise RemoteProtocolError("invalid session response")
|
|
235
|
+
return response
|
|
236
|
+
|
|
237
|
+
def _create_session(self, key):
|
|
238
|
+
response = self._session(self._request("POST", "/v1/sessions", payload={}, key=key))
|
|
239
|
+
if response["state"] != "active":
|
|
240
|
+
raise RemoteSubmissionError("session_closed", "client session has ended")
|
|
241
|
+
return response
|
|
242
|
+
|
|
243
|
+
def _heartbeat(self, session_id):
|
|
244
|
+
response = self._session(self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}:heartbeat",
|
|
245
|
+
payload={}, attempts=1, budget=30))
|
|
246
|
+
if response["session_id"] != session_id or response["state"] != "active":
|
|
247
|
+
raise RemoteProtocolError("invalid session heartbeat")
|
|
248
|
+
return response
|
|
249
|
+
|
|
250
|
+
def _close_session(self, session_id, budget):
|
|
251
|
+
response = self._session(self._request("POST", f"/v1/sessions/{quote(session_id, safe='')}:close",
|
|
252
|
+
payload={}, attempts=1, budget=budget))
|
|
253
|
+
if response["session_id"] != session_id or response["state"] == "active":
|
|
254
|
+
raise RemoteProtocolError("session cancellation was not persisted")
|
|
255
|
+
return response
|
annoscript/metrics.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Version 1 metric values and validation (stdlib only, Python 3.10+).
|
|
2
|
+
|
|
3
|
+
Observed subtotals are never substituted for unknown totals. Token details such
|
|
4
|
+
as reasoning/cached tokens are subsets, not additional billable tokens.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import Counter
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from annoscript.job_protocol import TERMINAL, integer, validate_status
|
|
11
|
+
from annoscript.remote import RemoteProtocolError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_usage(usage):
|
|
15
|
+
result = {}
|
|
16
|
+
for field, aliases in (("input_tokens", ("input_tokens", "prompt_tokens")),
|
|
17
|
+
("output_tokens", ("output_tokens", "completion_tokens"))):
|
|
18
|
+
present = [key for key in aliases if type(usage) is dict and key in usage]
|
|
19
|
+
values = [usage[key] for key in present]
|
|
20
|
+
reason = ("missing" if not present else "invalid" if not all(integer(v) for v in values)
|
|
21
|
+
else "conflicting" if len(set(values)) != 1 else None)
|
|
22
|
+
result[field] = {"value": values[0] if reason is None else None,
|
|
23
|
+
"sources": present, "reason": reason}
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def quantile(values, fraction):
|
|
28
|
+
return sorted(values)[max(0, math.ceil(len(values) * fraction) - 1)] if values else None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def measure(values, *, history=True, duration=False):
|
|
32
|
+
known = [value for value in values if value is not None]
|
|
33
|
+
result = {"observed": sum(known), "total": sum(known) if history and len(known) == len(values) else None,
|
|
34
|
+
"known": len(known), "unknown": len(values) - len(known)}
|
|
35
|
+
if duration:
|
|
36
|
+
result.update(p50=quantile(known, .5), p95=quantile(known, .95))
|
|
37
|
+
return result
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def aggregate(attempts, requests, *, history, waits):
|
|
41
|
+
"""Aggregate sanitized persisted records; never distribute batch usage by question."""
|
|
42
|
+
history = history and all(a["metered"] for a in attempts)
|
|
43
|
+
result = {
|
|
44
|
+
"history_complete": bool(history),
|
|
45
|
+
"attempts": {"count": len(attempts), "statuses": dict(sorted(Counter(a["status"] for a in attempts).items())),
|
|
46
|
+
"seconds": measure([a["duration_seconds"] for a in attempts], history=history, duration=True)},
|
|
47
|
+
"queue_wait_seconds": measure(waits, history=history, duration=True),
|
|
48
|
+
"backends": {},
|
|
49
|
+
}
|
|
50
|
+
for backend in ("decision", "generation"):
|
|
51
|
+
rows = [r for r in requests if r["backend"] == backend]
|
|
52
|
+
unknown = sum(r["http_status"] is None for r in rows)
|
|
53
|
+
item = {"requests": len(rows), "confirmed_requests": len(rows) - unknown,
|
|
54
|
+
"unknown_requests": unknown, "total_requests": len(rows) if history and not unknown else None,
|
|
55
|
+
"questions": sum(r["questions"] for r in rows),
|
|
56
|
+
"confirmed_questions": sum(r["questions"] for r in rows if r["http_status"] is not None),
|
|
57
|
+
"total_questions": sum(r["questions"] for r in rows) if history and not unknown else None,
|
|
58
|
+
"http_seconds": measure([r["seconds"] for r in rows], history=history, duration=True)}
|
|
59
|
+
for field in ("input_tokens", "output_tokens"):
|
|
60
|
+
tokens = [r["usage"][field] for r in rows]
|
|
61
|
+
item[field] = {**measure([t["value"] for t in tokens], history=history),
|
|
62
|
+
"sources": sorted({s for t in tokens for s in t["sources"]}),
|
|
63
|
+
"reasons": dict(sorted(Counter(t["reason"] for t in tokens if t["reason"]).items()))}
|
|
64
|
+
result["backends"][backend] = item
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def validate_metrics(value, job_id):
|
|
69
|
+
"""Reject all malformed JSON shapes through the public protocol error type."""
|
|
70
|
+
try:
|
|
71
|
+
return _validate_metrics(value, job_id)
|
|
72
|
+
except RemoteProtocolError:
|
|
73
|
+
raise
|
|
74
|
+
except (KeyError, TypeError, ValueError, OverflowError, RecursionError):
|
|
75
|
+
raise RemoteProtocolError("invalid job metrics") from None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _validate_metrics(value, job_id):
|
|
79
|
+
def require(ok):
|
|
80
|
+
if not ok:
|
|
81
|
+
raise RemoteProtocolError("invalid job metrics")
|
|
82
|
+
|
|
83
|
+
def number(v):
|
|
84
|
+
return type(v) in (int, float) and 0 <= v < float("inf")
|
|
85
|
+
|
|
86
|
+
def metric(m, *, duration=False, tokens=False):
|
|
87
|
+
keys = {"observed", "total", "known", "unknown"}
|
|
88
|
+
if duration:
|
|
89
|
+
keys |= {"p50", "p95"}
|
|
90
|
+
if tokens:
|
|
91
|
+
keys |= {"sources", "reasons"}
|
|
92
|
+
require(type(m) is dict and set(m) == keys)
|
|
93
|
+
require(integer(m["known"]) and integer(m["unknown"]) and number(m["observed"]))
|
|
94
|
+
require(m["total"] is None or (number(m["total"]) and m["total"] == m["observed"] and not m["unknown"]))
|
|
95
|
+
require(m["known"] != 0 or m["observed"] == 0)
|
|
96
|
+
if tokens:
|
|
97
|
+
require(integer(m["observed"]) and (m["total"] is None or integer(m["total"])))
|
|
98
|
+
require(type(m["sources"]) is list and all(s in ("input_tokens", "prompt_tokens", "output_tokens", "completion_tokens") for s in m["sources"]))
|
|
99
|
+
require(len(set(m["sources"])) == len(m["sources"]))
|
|
100
|
+
require(type(m["reasons"]) is dict and all(k in ("missing", "invalid", "conflicting") and integer(v, 1) for k, v in m["reasons"].items()))
|
|
101
|
+
require(sum(m["reasons"].values()) == m["unknown"])
|
|
102
|
+
if duration:
|
|
103
|
+
require(all((m[k] is None if not m["known"] else number(m[k])) for k in ("p50", "p95")))
|
|
104
|
+
require(not m["known"] or m["p50"] <= m["p95"] <= m["observed"] + 1e-9)
|
|
105
|
+
|
|
106
|
+
def summary(s):
|
|
107
|
+
require(type(s) is dict and set(s) == {"history_complete", "attempts", "queue_wait_seconds", "backends"})
|
|
108
|
+
require(type(s["history_complete"]) is bool)
|
|
109
|
+
a = s["attempts"]
|
|
110
|
+
require(type(a) is dict and set(a) == {"count", "statuses", "seconds"} and integer(a["count"]))
|
|
111
|
+
require(type(a["statuses"]) is dict and all(k in ("running", "finished", "failed", "cancelled", "abandoned") and integer(v, 1) for k, v in a["statuses"].items()))
|
|
112
|
+
require(sum(a["statuses"].values()) == a["count"])
|
|
113
|
+
metric(a["seconds"], duration=True)
|
|
114
|
+
require(a["seconds"]["known"] + a["seconds"]["unknown"] == a["count"])
|
|
115
|
+
metric(s["queue_wait_seconds"], duration=True)
|
|
116
|
+
require(s["history_complete"] or (a["seconds"]["total"] is None and s["queue_wait_seconds"]["total"] is None))
|
|
117
|
+
require(type(s["backends"]) is dict and set(s["backends"]) == {"decision", "generation"})
|
|
118
|
+
for b in s["backends"].values():
|
|
119
|
+
require(type(b) is dict and set(b) == {"requests", "confirmed_requests", "unknown_requests", "total_requests", "questions", "confirmed_questions", "total_questions", "http_seconds", "input_tokens", "output_tokens"})
|
|
120
|
+
require(all(integer(b[k]) for k in ("requests", "confirmed_requests", "unknown_requests", "questions", "confirmed_questions")))
|
|
121
|
+
require(b["confirmed_requests"] + b["unknown_requests"] == b["requests"] and b["confirmed_questions"] <= b["questions"])
|
|
122
|
+
complete = s["history_complete"] and not b["unknown_requests"]
|
|
123
|
+
require(all(b[k] is None or integer(b[k]) for k in ("total_requests", "total_questions")))
|
|
124
|
+
require(b["total_requests"] == (b["requests"] if complete else None))
|
|
125
|
+
require(b["total_questions"] == (b["questions"] if complete else None))
|
|
126
|
+
for key in ("http_seconds", "input_tokens", "output_tokens"):
|
|
127
|
+
metric(b[key], duration=key == "http_seconds", tokens=key != "http_seconds")
|
|
128
|
+
require(b[key]["known"] + b[key]["unknown"] == b["requests"])
|
|
129
|
+
require(s["history_complete"] or b[key]["total"] is None)
|
|
130
|
+
return s
|
|
131
|
+
|
|
132
|
+
require(type(value) is dict and set(value) == {"protocol_version", "metrics_version", "job", "provisional", "summary", "inputs"})
|
|
133
|
+
require(type(value["protocol_version"]) is int and value["protocol_version"] == 1)
|
|
134
|
+
require(type(value["metrics_version"]) is int and value["metrics_version"] == 1)
|
|
135
|
+
job = validate_status(value["job"])
|
|
136
|
+
require(job["job_id"] == job_id and type(value["provisional"]) is bool and value["provisional"] == (job["state"] not in TERMINAL))
|
|
137
|
+
summary(value["summary"])
|
|
138
|
+
require(type(value["inputs"]) is list and len(value["inputs"]) == len(job["input_indices"]))
|
|
139
|
+
indices = []
|
|
140
|
+
for item in value["inputs"]:
|
|
141
|
+
require(type(item) is dict and set(item) == {"index", "metrics"} and integer(item["index"]))
|
|
142
|
+
indices.append(item["index"])
|
|
143
|
+
summary(item["metrics"])
|
|
144
|
+
require(indices == job["input_indices"])
|
|
145
|
+
require(value["summary"]["attempts"]["count"] == sum(i["metrics"]["attempts"]["count"] for i in value["inputs"]))
|
|
146
|
+
for backend in ("decision", "generation"):
|
|
147
|
+
for k in ("requests", "questions", "unknown_requests", "confirmed_requests", "confirmed_questions"):
|
|
148
|
+
require(value["summary"]["backends"][backend][k] == sum(i["metrics"]["backends"][backend][k] for i in value["inputs"]))
|
|
149
|
+
for k in ("input_tokens", "output_tokens", "http_seconds"):
|
|
150
|
+
observed = sum(i["metrics"]["backends"][backend][k]["observed"] for i in value["inputs"])
|
|
151
|
+
actual = value["summary"]["backends"][backend][k]["observed"]
|
|
152
|
+
require(math.isclose(actual, observed, rel_tol=1e-12, abs_tol=1e-9) if k == "http_seconds" else actual == observed)
|
|
153
|
+
return value
|