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.
Files changed (128) hide show
  1. annoscript/__init__.py +1 -0
  2. annoscript/_backend_profiles.py +17 -0
  3. annoscript/_client_common.py +287 -0
  4. annoscript/_data.py +37 -0
  5. annoscript/_dispatch_pool.py +47 -0
  6. annoscript/_env.py +19 -0
  7. annoscript/_examples.py +34 -0
  8. annoscript/_expressions.py +93 -0
  9. annoscript/_plan_viewer.html +953 -0
  10. annoscript/_remote_jobs.py +211 -0
  11. annoscript/_request_adapters.py +71 -0
  12. annoscript/_speculation.py +321 -0
  13. annoscript/_test_artifacts.py +33 -0
  14. annoscript/_test_evaluation_http.py +117 -0
  15. annoscript/_test_ir_runtime.py +138 -0
  16. annoscript/_test_job_process.py +166 -0
  17. annoscript/_test_plan_runtime.py +331 -0
  18. annoscript/_test_remote_client.py +46 -0
  19. annoscript/_test_remote_server.py +75 -0
  20. annoscript/_test_synthetic.py +67 -0
  21. annoscript/anno.py +270 -0
  22. annoscript/backends/__init__.py +13 -0
  23. annoscript/backends/_batch_wire.py +26 -0
  24. annoscript/backends/_bounded_grammar.py +97 -0
  25. annoscript/backends/_example_layout.py +23 -0
  26. annoscript/backends/_generation_policy.py +116 -0
  27. annoscript/backends/_grammar.py +83 -0
  28. annoscript/backends/_preview.py +13 -0
  29. annoscript/backends/_prompt.py +115 -0
  30. annoscript/backends/_shared_rules.py +38 -0
  31. annoscript/backends/adapters/__init__.py +17 -0
  32. annoscript/backends/adapters/chat.py +39 -0
  33. annoscript/backends/adapters/decision.py +119 -0
  34. annoscript/backends/base.py +427 -0
  35. annoscript/backends/config.py +67 -0
  36. annoscript/backends/decision.py +150 -0
  37. annoscript/backends/llm.py +338 -0
  38. annoscript/client.py +247 -0
  39. annoscript/client_tests.py +17 -0
  40. annoscript/compiler.py +708 -0
  41. annoscript/cross_version_tests.py +68 -0
  42. annoscript/fixtures/request-adapters-v1.json +4741 -0
  43. annoscript/formatter.py +171 -0
  44. annoscript/grammar_tests.py +176 -0
  45. annoscript/inspect.py +77 -0
  46. annoscript/ir.py +458 -0
  47. annoscript/job_cross_version_tests.py +118 -0
  48. annoscript/job_protocol.py +180 -0
  49. annoscript/jobs.py +255 -0
  50. annoscript/metrics.py +153 -0
  51. annoscript/p0_grammar_tests.py +147 -0
  52. annoscript/passes/__init__.py +40 -0
  53. annoscript/passes/_analysis.py +178 -0
  54. annoscript/passes/control_flow.py +68 -0
  55. annoscript/passes/dead_assignments.py +44 -0
  56. annoscript/passes/folding.py +104 -0
  57. annoscript/passes/propagation.py +53 -0
  58. annoscript/passes/temporaries.py +45 -0
  59. annoscript/plan.py +542 -0
  60. annoscript/plan_formatter.py +452 -0
  61. annoscript/plan_lowering.py +571 -0
  62. annoscript/preview.py +115 -0
  63. annoscript/remote.py +304 -0
  64. annoscript/runtime.py +861 -0
  65. annoscript/server/__init__.py +1 -0
  66. annoscript/server/__main__.py +49 -0
  67. annoscript/server/_plan_cache.py +79 -0
  68. annoscript/server/api.py +146 -0
  69. annoscript/server/metrics.py +140 -0
  70. annoscript/server/migrate.py +61 -0
  71. annoscript/server/releases.py +143 -0
  72. annoscript/server/store.py +429 -0
  73. annoscript/server/worker.py +290 -0
  74. annoscript/substring_tests.py +79 -0
  75. annoscript/test_advisory_lengths.py +115 -0
  76. annoscript/test_backends.py +181 -0
  77. annoscript/test_client.py +349 -0
  78. annoscript/test_compact_batch.py +95 -0
  79. annoscript/test_compiler.py +444 -0
  80. annoscript/test_decision_adapters.py +410 -0
  81. annoscript/test_decision_wire.py +190 -0
  82. annoscript/test_diagnostics.py +97 -0
  83. annoscript/test_engineering_p0.py +191 -0
  84. annoscript/test_environment.py +65 -0
  85. annoscript/test_example_backends.py +251 -0
  86. annoscript/test_examples.py +149 -0
  87. annoscript/test_field_names.py +221 -0
  88. annoscript/test_formatter.py +153 -0
  89. annoscript/test_fusion_scheduling.py +109 -0
  90. annoscript/test_generation_policy.py +180 -0
  91. annoscript/test_ir.py +148 -0
  92. annoscript/test_jobs.py +371 -0
  93. annoscript/test_jobs_server.py +511 -0
  94. annoscript/test_llm_batch.py +428 -0
  95. annoscript/test_metrics.py +59 -0
  96. annoscript/test_metrics_server.py +261 -0
  97. annoscript/test_minimal_fusion.py +59 -0
  98. annoscript/test_nullable_generate.py +169 -0
  99. annoscript/test_passes.py +408 -0
  100. annoscript/test_plan.py +522 -0
  101. annoscript/test_plan_constraints.py +305 -0
  102. annoscript/test_plan_formatter.py +272 -0
  103. annoscript/test_prompt_layout.py +140 -0
  104. annoscript/test_qwen_backend.py +181 -0
  105. annoscript/test_qwen_pilot_regression.py +180 -0
  106. annoscript/test_release_client.py +20 -0
  107. annoscript/test_remote.py +308 -0
  108. annoscript/test_request_adapters.py +295 -0
  109. annoscript/test_requirements.py +228 -0
  110. annoscript/test_rule_dedup.py +67 -0
  111. annoscript/test_runtime.py +314 -0
  112. annoscript/test_runtime_client.py +133 -0
  113. annoscript/test_source_fields.py +111 -0
  114. annoscript/test_speculation.py +458 -0
  115. annoscript/test_substring_backend.py +81 -0
  116. annoscript/test_synthetic_example.py +61 -0
  117. annoscript-0.1.0.dist-info/METADATA +145 -0
  118. annoscript-0.1.0.dist-info/RECORD +128 -0
  119. annoscript-0.1.0.dist-info/WHEEL +4 -0
  120. annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
  121. annoscript_examples/__init__.py +1 -0
  122. annoscript_examples/fixtures.py +3 -0
  123. annoscript_examples/request.execution_plan.json +3017 -0
  124. annoscript_examples/request.json +858 -0
  125. annoscript_examples/request.py +37 -0
  126. annoscript_examples/request_preview.decision.json +23 -0
  127. annoscript_examples/request_preview.generation.json +20 -0
  128. annoscript_examples/run.py +24 -0
@@ -0,0 +1 @@
1
+ """Single-host durable execution service (Python 3.12+). Imported only explicitly."""
@@ -0,0 +1,49 @@
1
+ """python -m annoscript.server {api,worker} --db jobs.sqlite --config service.json"""
2
+
3
+ import argparse
4
+ import asyncio
5
+ import signal
6
+
7
+ from annoscript.server.releases import load_config
8
+ from annoscript.server.store import Store
9
+
10
+
11
+ def main():
12
+ parser = argparse.ArgumentParser(description=__doc__)
13
+ parser.add_argument("command", choices=("api", "worker", "releases"))
14
+ parser.add_argument("--db", default="anno-jobs.sqlite")
15
+ parser.add_argument("--config", required=True)
16
+ parser.add_argument("--host", default="127.0.0.1")
17
+ parser.add_argument("--port", type=int, default=8000)
18
+ parser.add_argument("--concurrency", type=int, default=8)
19
+ parser.add_argument("--max-generation-inflight", type=int)
20
+ parser.add_argument("--max-decision-inflight", type=int)
21
+ args = parser.parse_args()
22
+ releases, tokens = load_config(args.config)
23
+ if args.command == "releases":
24
+ from annoscript.job_protocol import encode
25
+ print(encode({"default_release": releases.default, "releases": releases.manifests}).decode())
26
+ return
27
+ store = Store(args.db)
28
+ if args.command == "api":
29
+ import uvicorn
30
+ from annoscript.server.api import create_app
31
+ uvicorn.run(create_app(store, releases, tokens), host=args.host, port=args.port, access_log=False)
32
+ else:
33
+ from annoscript.server.worker import Worker
34
+ limits = None
35
+ if args.max_generation_inflight is not None or args.max_decision_inflight is not None:
36
+ if args.max_generation_inflight is None or args.max_decision_inflight is None:
37
+ parser.error("both shared model limits must be provided")
38
+ limits = {"generation": args.max_generation_inflight, "decision": args.max_decision_inflight}
39
+ async def serve():
40
+ stop = asyncio.Event()
41
+ loop = asyncio.get_running_loop()
42
+ for sig in (signal.SIGINT, signal.SIGTERM):
43
+ loop.add_signal_handler(sig, stop.set)
44
+ await Worker(store, releases, concurrency=args.concurrency, model_request_limits=limits).run(stop=stop)
45
+ asyncio.run(serve())
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
@@ -0,0 +1,79 @@
1
+ """Worker-local bounded plan cache. Never contains input or inference state."""
2
+
3
+ import asyncio
4
+ from collections import OrderedDict
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ import hashlib
7
+ import time
8
+
9
+ from annoscript.job_protocol import encode
10
+
11
+ MAX_ENTRIES = 32
12
+ MAX_BYTES = 64 * 1024 * 1024
13
+
14
+
15
+ class PlanCache:
16
+ def __init__(self, *, max_entries=MAX_ENTRIES, max_bytes=MAX_BYTES):
17
+ self.max_entries, self.max_bytes = max_entries, max_bytes
18
+ self.entries, self.pending = OrderedDict(), {}
19
+ self.bytes = 0
20
+ self.stats = dict(hits=0, misses=0, waits=0, compiles=0, failures=0, evictions=0)
21
+ self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="anno-plan")
22
+ self.closed = False
23
+
24
+ async def get(self, release_id, ir, compile_plan):
25
+ if self.closed:
26
+ raise RuntimeError("plan cache is closed")
27
+ started = time.monotonic()
28
+ encoded = encode(ir, canonical=True)
29
+ key = (release_id, hashlib.sha256(encoded).hexdigest())
30
+ status = "hit"
31
+ if key in self.entries:
32
+ self.stats["hits"] += 1
33
+ entry = self.entries[key]
34
+ self.entries.move_to_end(key)
35
+ else:
36
+ status = "wait" if key in self.pending else "miss"
37
+ self.stats["waits" if status == "wait" else "misses"] += 1
38
+ if status == "miss":
39
+ self.pending[key] = asyncio.create_task(self._compile(key, encoded, compile_plan))
40
+ # Retrieve exceptions even if every input waiting for this plan was cancelled.
41
+ self.pending[key].add_done_callback(lambda t: None if t.cancelled() else t.exception())
42
+ entry = await asyncio.shield(self.pending[key])
43
+ plan, size, compile_seconds = entry
44
+ return plan, {"event": "plan_cache", "status": status, "ir_sha256": key[1],
45
+ "seconds": time.monotonic() - started,
46
+ "compile_seconds": compile_seconds if status != "hit" else 0.0,
47
+ "plan_bytes": size, "cached_bytes": self.bytes,
48
+ "cached_entries": len(self.entries), "counters": dict(self.stats)}
49
+
50
+ async def _compile(self, key, encoded, compile_plan):
51
+ def build():
52
+ started = time.monotonic()
53
+ plan = compile_plan(encoded)
54
+ size = len(plan.to_json(indent=None).encode("utf-8"))
55
+ return plan, size, time.monotonic() - started
56
+ try:
57
+ entry = await asyncio.get_running_loop().run_in_executor(self.executor, build)
58
+ self.stats["compiles"] += 1
59
+ size = entry[1]
60
+ if self.max_entries > 0 and size <= self.max_bytes:
61
+ while self.entries and (len(self.entries) >= self.max_entries or self.bytes + size > self.max_bytes):
62
+ _, old = self.entries.popitem(last=False)
63
+ self.bytes -= old[1]
64
+ self.stats["evictions"] += 1
65
+ self.entries[key] = entry
66
+ self.bytes += size
67
+ return entry
68
+ except Exception:
69
+ self.stats["failures"] += 1
70
+ raise
71
+ finally:
72
+ self.pending.pop(key, None)
73
+
74
+ async def aclose(self):
75
+ self.closed = True
76
+ await asyncio.gather(*self.pending.values(), return_exceptions=True)
77
+ self.executor.shutdown(wait=True)
78
+ self.entries.clear()
79
+ self.bytes = 0
@@ -0,0 +1,146 @@
1
+ """Short-request JSON API. No model backend is constructed by this process."""
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager, suppress
5
+ import hmac
6
+
7
+ from fastapi import Depends, FastAPI, Request
8
+ from fastapi.responses import JSONResponse
9
+
10
+ from annoscript.job_protocol import decode, integer, nonempty
11
+ from annoscript.remote import RemoteProtocolError
12
+ from annoscript.server.store import ServiceError
13
+
14
+
15
+ def create_app(store, releases, tokens):
16
+ if not tokens or any(not nonempty(k) or not nonempty(v) for k, v in tokens.items()):
17
+ raise ValueError("service tokens must map to nonempty principals")
18
+
19
+ @asynccontextmanager
20
+ async def lifespan(app):
21
+ async def maintain():
22
+ while True:
23
+ await asyncio.to_thread(store.maintain)
24
+ await asyncio.sleep(1)
25
+ await asyncio.to_thread(store.maintain)
26
+ task = asyncio.create_task(maintain())
27
+ try:
28
+ yield
29
+ finally:
30
+ task.cancel()
31
+ with suppress(asyncio.CancelledError):
32
+ await task
33
+
34
+ app = FastAPI(lifespan=lifespan, docs_url=None, redoc_url=None, openapi_url=None,
35
+ redirect_slashes=False)
36
+
37
+ @app.exception_handler(ServiceError)
38
+ async def service_error(request, error):
39
+ return JSONResponse({"error": {"code": error.code, "message": str(error)}}, status_code=error.status)
40
+
41
+ def principal(request: Request):
42
+ auth = request.headers.get("Authorization", "")
43
+ if not auth.lower().startswith("bearer "):
44
+ raise ServiceError("authentication_failed", "service authentication required", 401)
45
+ supplied = auth[7:]
46
+ for token, identity in tokens.items():
47
+ if hmac.compare_digest(supplied.encode(), token.encode()):
48
+ return identity
49
+ raise ServiceError("authentication_failed", "invalid service token", 401)
50
+
51
+ def key(request):
52
+ value = request.headers.get("Idempotency-Key")
53
+ if not nonempty(value) or len(value) > 256:
54
+ raise ServiceError("invalid_idempotency_key", "a nonempty Idempotency-Key of at most 256 characters is required")
55
+ return value
56
+
57
+ async def body(request, *, empty=False):
58
+ limit = max(m["policy"]["max_request_bytes"] for m in releases.manifests.values())
59
+ data = bytearray()
60
+ async for chunk in request.stream():
61
+ data.extend(chunk)
62
+ if len(data) > limit:
63
+ raise ServiceError("request_too_large", "request exceeds service body limit", 413)
64
+ if not data and empty:
65
+ return {}
66
+ if request.headers.get("content-type", "").split(";", 1)[0].lower() != "application/json":
67
+ raise ServiceError("invalid_content_type", "expected application/json", 415)
68
+ try:
69
+ value = decode(bytes(data))
70
+ except RemoteProtocolError as exc:
71
+ raise ServiceError("invalid_json", str(exc)) from None
72
+ if type(value) is not dict:
73
+ raise ServiceError("invalid_request", "request must be an object")
74
+ return value
75
+
76
+ def envelope(job):
77
+ return {"protocol_version": 1, "job": job}
78
+
79
+ @app.post("/v1/jobs", status_code=202)
80
+ async def submit(request: Request, identity=Depends(principal)):
81
+ idem = key(request)
82
+ payload = await body(request)
83
+ try:
84
+ job = await asyncio.to_thread(store.submit, identity, idem, payload, releases)
85
+ except (ValueError, TypeError, RecursionError) as exc:
86
+ raise ServiceError("invalid_request", "invalid job request or IR") from exc
87
+ return envelope(job)
88
+
89
+ @app.get("/v1/jobs:lookup")
90
+ def lookup(request: Request, identity=Depends(principal)):
91
+ return envelope(store.lookup(identity, key(request)))
92
+
93
+ @app.get("/v1/jobs/{job_id}")
94
+ def status(job_id: str, identity=Depends(principal)):
95
+ return envelope(store.status(identity, job_id))
96
+
97
+ @app.get("/v1/jobs/{job_id}/results")
98
+ def results(job_id: str, request: Request, identity=Depends(principal)):
99
+ try:
100
+ limit = int(request.query_params.get("limit", "100"))
101
+ except ValueError:
102
+ raise ServiceError("invalid_limit", "invalid page limit") from None
103
+ return store.results(identity, job_id, request.query_params.get("cursor"), limit)
104
+
105
+ @app.get("/v1/jobs/{job_id}/metrics")
106
+ def metrics(job_id: str, identity=Depends(principal)):
107
+ return store.metrics(identity, job_id)
108
+
109
+ @app.post("/v1/jobs/{job_id}:resume")
110
+ async def resume(job_id: str, request: Request, identity=Depends(principal)):
111
+ idem = key(request)
112
+ payload = await body(request)
113
+ if set(payload) != {"pause_generation"} or not integer(payload["pause_generation"], 1):
114
+ raise ServiceError("invalid_request", "resume requires a positive pause_generation")
115
+ return envelope(await asyncio.to_thread(store.resume, identity, job_id, idem, payload["pause_generation"], releases))
116
+
117
+ @app.post("/v1/jobs/{job_id}:cancel")
118
+ async def cancel(job_id: str, request: Request, identity=Depends(principal)):
119
+ if await body(request, empty=True):
120
+ raise ServiceError("invalid_request", "cancel body must be empty")
121
+ return envelope(await asyncio.to_thread(store.cancel, identity, job_id))
122
+
123
+ @app.get("/v1/releases/{release_id}")
124
+ def release(release_id: str, identity=Depends(principal)):
125
+ return {"protocol_version": 1, **store.release(release_id, releases)}
126
+
127
+ @app.post("/v1/sessions", status_code=201)
128
+ async def create_session(request: Request, identity=Depends(principal)):
129
+ idem = key(request)
130
+ if await body(request, empty=True):
131
+ raise ServiceError("invalid_request", "session body must be empty")
132
+ return {"protocol_version": 1, **await asyncio.to_thread(store.create_session, identity, idem)}
133
+
134
+ @app.post("/v1/sessions/{session_id}:heartbeat")
135
+ async def heartbeat(session_id: str, request: Request, identity=Depends(principal)):
136
+ if await body(request, empty=True):
137
+ raise ServiceError("invalid_request", "heartbeat body must be empty")
138
+ return {"protocol_version": 1, **await asyncio.to_thread(store.heartbeat_session, identity, session_id)}
139
+
140
+ @app.post("/v1/sessions/{session_id}:close")
141
+ async def close_session(session_id: str, request: Request, identity=Depends(principal)):
142
+ if await body(request, empty=True):
143
+ raise ServiceError("invalid_request", "close body must be empty")
144
+ return {"protocol_version": 1, **await asyncio.to_thread(store.close_session, identity, session_id)}
145
+
146
+ return app
@@ -0,0 +1,140 @@
1
+ """Fenced metric persistence. No provider bodies or client-side journal."""
2
+
3
+ import asyncio
4
+ import time
5
+ import uuid
6
+
7
+ from annoscript.job_protocol import TERMINAL, decode, encode
8
+ from annoscript.metrics import aggregate, normalize_usage
9
+ from annoscript.backends.base import BackendFatalError
10
+
11
+
12
+ UPGRADE = """
13
+ ALTER TABLE jobs ADD COLUMN metrics_available INTEGER NOT NULL DEFAULT 0;
14
+ ALTER TABLE jobs ADD COLUMN queued_monotonic REAL;
15
+ ALTER TABLE jobs ADD COLUMN clock_epoch TEXT;
16
+ ALTER TABLE attempts ADD COLUMN metered INTEGER NOT NULL DEFAULT 0;
17
+ ALTER TABLE attempts ADD COLUMN duration_seconds REAL;
18
+ ALTER TABLE attempts ADD COLUMN queue_wait_seconds REAL;
19
+ CREATE TABLE http_requests (
20
+ id TEXT PRIMARY KEY, job_id TEXT NOT NULL, idx INTEGER NOT NULL, attempt INTEGER NOT NULL,
21
+ backend TEXT NOT NULL, questions INTEGER NOT NULL, http_status INTEGER, seconds REAL, usage BLOB,
22
+ FOREIGN KEY(job_id,idx,attempt) REFERENCES attempts(job_id,idx,number));
23
+ CREATE INDEX http_requests_job ON http_requests(job_id,idx,attempt);
24
+ PRAGMA user_version=2;
25
+ """
26
+
27
+
28
+ class MetricStore:
29
+ def meter_attempt(self, claim, supported):
30
+ with self._tx() as db:
31
+ if self._owned(db, claim) is None:
32
+ return False
33
+ db.execute("UPDATE attempts SET metered=? WHERE job_id=? AND idx=? AND number=?",
34
+ (int(supported), claim["job_id"], claim["index"], claim["attempt"]))
35
+ return True
36
+
37
+ def request_started(self, claim, request_id, backend, questions):
38
+ if backend not in ("decision", "generation") or type(questions) is not int or questions < 1:
39
+ raise ValueError("invalid request metrics")
40
+ with self._tx() as db:
41
+ owned = self._owned(db, claim)
42
+ if owned is None or owned[0]["state"] not in ("queued", "running"):
43
+ return False
44
+ args = (request_id, claim["job_id"], claim["index"], claim["attempt"], backend, questions)
45
+ old = db.execute("SELECT id,job_id,idx,attempt,backend,questions FROM http_requests WHERE id=?", (request_id,)).fetchone()
46
+ if old is not None and tuple(old) != args:
47
+ raise ValueError("request metric identity conflict")
48
+ db.execute("INSERT OR IGNORE INTO http_requests(id,job_id,idx,attempt,backend,questions) VALUES (?,?,?,?,?,?)", args)
49
+ return True
50
+
51
+ def request_finished(self, claim, request_id, *, seconds, http_status, usage):
52
+ if type(seconds) not in (int, float) or not 0 <= seconds < float("inf"):
53
+ raise ValueError("invalid HTTP duration")
54
+ if http_status is not None and (type(http_status) is not int or not 100 <= http_status <= 599):
55
+ raise ValueError("invalid HTTP status")
56
+ with self._tx() as db:
57
+ if self._owned(db, claim) is None:
58
+ return False
59
+ db.execute("""UPDATE http_requests SET seconds=?,http_status=?,usage=?
60
+ WHERE id=? AND job_id=? AND idx=? AND attempt=? AND seconds IS NULL""",
61
+ (seconds, http_status, encode(normalize_usage(usage)), request_id,
62
+ claim["job_id"], claim["index"], claim["attempt"]))
63
+ return True
64
+
65
+ def _attempt_duration(self, db, claim, seconds):
66
+ if seconds is not None and (type(seconds) not in (int, float) or not 0 <= seconds < float("inf")):
67
+ raise ValueError("invalid attempt duration")
68
+ db.execute("UPDATE attempts SET duration_seconds=? WHERE job_id=? AND idx=? AND number=?",
69
+ (seconds, claim["job_id"], claim["index"], claim["attempt"]))
70
+
71
+ def metrics(self, principal, job_id):
72
+ with self._tx() as db:
73
+ job = self._job(db, job_id, principal)
74
+ status = self._status(db, job)
75
+ attempts = [dict(r) for r in db.execute("SELECT * FROM attempts WHERE job_id=? ORDER BY idx,number", (job_id,))]
76
+ requests = [dict(r) for r in db.execute("SELECT * FROM http_requests WHERE job_id=? ORDER BY id", (job_id,))]
77
+ for row in requests:
78
+ row["usage"] = decode(row["usage"]) if row["usage"] is not None else normalize_usage(None)
79
+ items, waits = [], []
80
+ for index in status["input_indices"]:
81
+ aa = [a for a in attempts if a["idx"] == index]
82
+ rr = [r for r in requests if r["idx"] == index]
83
+ # Queue intervals are valid only within the same monotonic
84
+ # clock epoch (boot); never subtract wall timestamps.
85
+ wait = aa[0]["queue_wait_seconds"] if aa else None
86
+ waits.append(wait)
87
+ items.append({"index": index, "metrics": aggregate(aa, rr, history=bool(job["metrics_available"]), waits=[wait])})
88
+ return {"protocol_version": 1, "metrics_version": 1, "job": status,
89
+ "provisional": status["state"] not in TERMINAL,
90
+ "summary": aggregate(attempts, requests, history=bool(job["metrics_available"]), waits=waits), "inputs": items}
91
+
92
+
93
+ class RequestMeter:
94
+ """One meter per owned attempt, invoked only at the actual HTTP boundary."""
95
+ def __init__(self, store, claim, backend, *, clock=time.monotonic, observer=None, wall_clock=time.time):
96
+ self.store, self.claim, self.backend, self.clock = store, claim, backend, clock
97
+ self.observer, self.wall_clock = observer, wall_clock
98
+
99
+ def _observe(self, event):
100
+ if self.observer is not None:
101
+ try:
102
+ self.observer({"job_id": self.claim["job_id"], "index": self.claim["index"],
103
+ "attempt": self.claim["attempt"], "stage": self.backend, **event})
104
+ except Exception:
105
+ raise BackendFatalError("request audit persistence failed", code="metrics_failed") from None
106
+
107
+ async def start(self, questions):
108
+ identity = uuid.uuid4().hex
109
+ try:
110
+ committed = await asyncio.to_thread(self.store.request_started, self.claim, identity, self.backend, questions)
111
+ except Exception:
112
+ raise BackendFatalError("request intent persistence failed", code="metrics_failed") from None
113
+ if not committed:
114
+ if self.store.renew(self.claim):
115
+ raise BackendFatalError("job is paused; no new model request was sent", code="job_paused")
116
+ raise asyncio.CancelledError()
117
+ self._observe({"event": "intent", "request_id": identity, "questions": questions,
118
+ "started_unix": self.wall_clock()})
119
+ return identity
120
+
121
+ async def finish(self, identity, elapsed, status, usage):
122
+ outcome = {"event": "outcome", "request_id": identity, "seconds": elapsed,
123
+ "http_status": status, "usage": usage, "finished_unix": self.wall_clock()}
124
+ committed = False
125
+ pending = asyncio.create_task(asyncio.to_thread(self.store.request_finished, self.claim, identity,
126
+ seconds=elapsed, http_status=status, usage=usage))
127
+ try:
128
+ try:
129
+ committed = await asyncio.shield(pending)
130
+ except asyncio.CancelledError:
131
+ # Drain persistence and audit before propagating cancellation.
132
+ # Ownership fencing still applies to stale attempts.
133
+ committed = await pending
134
+ raise
135
+ except Exception:
136
+ raise BackendFatalError("request outcome persistence failed", code="metrics_failed") from None
137
+ finally:
138
+ self._observe({**outcome, "storage_committed": committed})
139
+ if not committed:
140
+ raise asyncio.CancelledError()
@@ -0,0 +1,61 @@
1
+ """Explicit offline v1 -> v2 migration; stop API and workers before invoking."""
2
+
3
+ import argparse
4
+ from contextlib import closing
5
+ import os
6
+ from pathlib import Path
7
+ import sqlite3
8
+
9
+ from annoscript.server.metrics import UPGRADE
10
+
11
+
12
+ def migrate(path, backup, *, _after_statement=None):
13
+ path, backup = Path(path), Path(backup)
14
+ if not path.is_file() or path.resolve() == backup.resolve():
15
+ raise ValueError("an existing database and a separate new backup path are required")
16
+ with closing(sqlite3.connect(path, isolation_level=None)) as db:
17
+ if db.execute("PRAGMA user_version").fetchone()[0] != 1:
18
+ raise RuntimeError("migration requires schema v1")
19
+ # O_EXCL protects an earlier backup. SQLite backup includes committed WAL.
20
+ fd = os.open(backup, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
21
+ os.close(fd)
22
+ with closing(sqlite3.connect(backup)) as target:
23
+ db.backup(target)
24
+ if target.execute("PRAGMA integrity_check").fetchone()[0] != "ok":
25
+ raise RuntimeError("backup integrity check failed")
26
+ with open(backup, "rb") as file:
27
+ os.fsync(file.fileno())
28
+ directory = os.open(backup.parent, os.O_RDONLY)
29
+ try:
30
+ os.fsync(directory)
31
+ finally:
32
+ os.close(directory)
33
+ db.execute("PRAGMA foreign_keys=ON")
34
+ db.execute("PRAGMA synchronous=FULL")
35
+ db.execute("BEGIN EXCLUSIVE")
36
+ try:
37
+ if db.execute("PRAGMA user_version").fetchone()[0] != 1:
38
+ raise RuntimeError("database changed during backup")
39
+ for statement in UPGRADE.split(";"):
40
+ if statement.strip():
41
+ db.execute(statement)
42
+ if _after_statement is not None:
43
+ _after_statement(statement)
44
+ if db.execute("PRAGMA foreign_key_check").fetchone() is not None:
45
+ raise RuntimeError("foreign key check failed")
46
+ db.commit()
47
+ except BaseException:
48
+ db.rollback()
49
+ raise
50
+
51
+
52
+ def main():
53
+ parser = argparse.ArgumentParser(description=__doc__)
54
+ parser.add_argument("--db", required=True)
55
+ parser.add_argument("--backup", required=True)
56
+ args = parser.parse_args()
57
+ migrate(args.db, args.backup)
58
+
59
+
60
+ if __name__ == "__main__":
61
+ main()
@@ -0,0 +1,143 @@
1
+ """Immutable execution manifests; credentials stay outside manifests and SQLite."""
2
+
3
+ import hashlib
4
+ from importlib.metadata import version
5
+ import os
6
+ from pathlib import Path
7
+ import sys
8
+ from annoscript._env import env_value
9
+ from urllib.parse import urlsplit
10
+
11
+ from annoscript.job_protocol import decode, encode
12
+ from annoscript._backend_profiles import validate_generation_profile, validate_extract_mode
13
+ from annoscript._request_adapters import (DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER,
14
+ validate_request_adapter, normalize_decision_options)
15
+ from annoscript.backends.base import LLM_BATCH_DEFAULTS, validate_batch_options
16
+ from annoscript.backends._generation_policy import validate_policy, validate_token_ratio
17
+ from annoscript.server._plan_cache import MAX_ENTRIES, MAX_BYTES
18
+ from annoscript.backends._shared_rules import validate_rule_dedup
19
+
20
+ RUNTIME = {"max_active_runs": 1, "max_decision_inflight": 4, "max_llm_inflight": 4,
21
+ "max_batch_questions": 32, "loop_window": 32, "local_task_budget": 1024, **LLM_BATCH_DEFAULTS}
22
+ POLICY = {"max_attempts": 3, "input_timeout": 300.0, "model_timeout": 60.0,
23
+ "max_inputs": 500, "max_request_bytes": 32 * 1024 * 1024,
24
+ "retry_unknown_outcomes": True}
25
+
26
+
27
+ def build_sha256():
28
+ root = Path(__file__).resolve().parents[1]
29
+ digest = hashlib.sha256()
30
+ for path in sorted(root.rglob("*.py")):
31
+ if path.name.startswith(("test_", "_test_")):
32
+ continue
33
+ digest.update(str(path.relative_to(root)).encode() + b"\0" + path.read_bytes() + b"\0")
34
+ return digest.hexdigest()
35
+
36
+
37
+ def manifest(config):
38
+ allowed = {"decision", "generation", "server_optimize", "runtime", "policy", "disable_thinking"}
39
+ if type(config) is not dict or set(config) - allowed:
40
+ raise ValueError("invalid release configuration")
41
+ models = {}
42
+ for name in ("decision", "generation"):
43
+ model = config.get(name)
44
+ if name == "generation" and isinstance(model, dict) and "source_layout" in model:
45
+ raise ValueError("source_layout was removed; extract now uses source_field")
46
+ required = {"base_url", "model", "revision"}
47
+ allowed_model = required | {"request_adapter"} | ({"profile", "extract_mode", "batch_rule_dedup", "structured_output_revision", "request_policy", "assumed_avg_num_tokens_per_char"} if name == "generation" else {"request_adapter_options"})
48
+ if (type(model) is not dict or not required <= set(model) or set(model) - allowed_model
49
+ or any(type(v) is not str or not v.strip() for k, v in model.items() if k not in ("request_policy", "assumed_avg_num_tokens_per_char", "batch_rule_dedup", "request_adapter_options"))):
50
+ raise ValueError(f"{name} needs base_url, model and immutable deployment revision")
51
+ url = urlsplit(model["base_url"])
52
+ if url.scheme not in ("http", "https") or not url.hostname or url.username or url.password or url.query or url.fragment:
53
+ raise ValueError("model URL must be HTTP(S), without credentials, query or fragment")
54
+ models[name] = model.copy()
55
+ models[name]["request_adapter"] = validate_request_adapter(
56
+ model.get("request_adapter", DEFAULT_DECISION_ADAPTER if name == "decision" else DEFAULT_GENERATION_ADAPTER), name)
57
+ if name == "decision":
58
+ if "request_adapter_options" in model and type(model["request_adapter_options"]) is not dict:
59
+ raise ValueError("request_adapter_options must be an object")
60
+ options = normalize_decision_options(models[name]["request_adapter"], model.get("request_adapter_options"))
61
+ if options is not None:
62
+ models[name]["request_adapter_options"] = options
63
+ else:
64
+ models[name].pop("request_adapter_options", None)
65
+ if name == "generation":
66
+ models[name]["assumed_avg_num_tokens_per_char"] = validate_token_ratio(model.get("assumed_avg_num_tokens_per_char", 2))
67
+ models[name]["profile"] = validate_generation_profile(model.get("profile", "legacy"))
68
+ if "batch_rule_dedup" in model:
69
+ models[name]["batch_rule_dedup"] = validate_rule_dedup(model["batch_rule_dedup"], models[name]["profile"])
70
+ # Omitted mode keeps existing release hashes stable for old manifests.
71
+ mode = validate_extract_mode(model.get("extract_mode", "validated"), models[name]["profile"])
72
+ if "extract_mode" in model:
73
+ models[name]["extract_mode"] = mode
74
+ if mode == "substring_chars" and not model.get("structured_output_revision"):
75
+ raise ValueError("substring_chars requires immutable structured_output_revision")
76
+ if "request_policy" in model:
77
+ request_policy = validate_policy(model["request_policy"])
78
+ if request_policy is None or models[name]["profile"] != "qwen_vllm":
79
+ raise ValueError("request_policy requires qwen_vllm")
80
+ if request_policy["tokenizer"]["revision"] != model["revision"]:
81
+ raise ValueError("tokenizer revision differs from generation revision")
82
+ if not model.get("structured_output_revision"):
83
+ raise ValueError("bounded outputs require immutable decoder build identity")
84
+ models[name]["request_policy"] = request_policy
85
+ runtime, policy = {**RUNTIME, **config.get("runtime", {})}, {**POLICY, **config.get("policy", {})}
86
+ validate_batch_options({k: runtime[k] for k in LLM_BATCH_DEFAULTS})
87
+ if (set(runtime) != set(RUNTIME) or any(type(v) is not int or v < 1 for k, v in runtime.items() if k != "llm_batch_repair_attempts")
88
+ or runtime["max_active_runs"] != 1 or set(policy) != set(POLICY)
89
+ or type(policy["retry_unknown_outcomes"]) is not bool
90
+ or any(type(v) not in (int, float) or not 0 < v < float("inf")
91
+ for k, v in policy.items() if k != "retry_unknown_outcomes")
92
+ or any(type(policy[k]) is not int for k in ("max_attempts", "max_inputs", "max_request_bytes"))):
93
+ raise ValueError("invalid release runtime or resource policy")
94
+ generation_policy = models["generation"].get("request_policy")
95
+ optimize, thinking = config.get("server_optimize", True), config.get("disable_thinking", True)
96
+ if type(optimize) is not bool or type(thinking) is not bool:
97
+ raise ValueError("release switches must be booleans")
98
+ return {"format": "lev-release", "version": 1, "build_sha256": build_sha256(),
99
+ "engineering": {"plan_cache": {"max_entries": MAX_ENTRIES, "max_serialized_bytes": MAX_BYTES,
100
+ "compiler_threads": 1, "key": "release_id+canonical_ir_sha256"},
101
+ "qwen_source_layout": "source_field_v1", "request_preparation": "immutable_v1",
102
+ "worker_refill": "completion_event_v1", "shared_model_slots": "ready_at_admission_fifo_v1",
103
+ "shared_slot_scope": "single_worker_process; limits bound by execution manifest"},
104
+ "python": f"{sys.version_info.major}.{sys.version_info.minor}", "ir_version": 1,
105
+ "dependencies": {name: version(name) for name in ("httpx", "httpcore", "fastapi", "starlette", "uvicorn")},
106
+ **models, "server_optimize": optimize, "disable_thinking": thinking,
107
+ "runtime": runtime, "policy": policy}
108
+
109
+
110
+ class Releases:
111
+ def __init__(self, configs, default):
112
+ self.manifests, self.aliases = {}, {}
113
+ for alias, config in configs.items():
114
+ value = manifest(config)
115
+ release_id = hashlib.sha256(encode(value, canonical=True)).hexdigest()
116
+ self.manifests[release_id] = value
117
+ self.aliases[alias] = release_id
118
+ if default not in self.aliases:
119
+ raise ValueError("default_release must name a configured release")
120
+ self.default = self.aliases[default]
121
+
122
+ def resolve(self, release_id=None):
123
+ release_id = release_id or self.default
124
+ if release_id not in self.manifests:
125
+ raise KeyError(release_id)
126
+ return release_id, self.manifests[release_id]
127
+
128
+
129
+ def load_config(path):
130
+ if sys.version_info < (3, 12):
131
+ raise RuntimeError("the AnnoScript job service requires Python 3.12+")
132
+ config = decode(Path(path).read_bytes())
133
+ if type(config) is not dict or set(config) != {"releases", "default_release", "auth"}:
134
+ raise ValueError("config requires releases, default_release and auth")
135
+ if type(config["auth"]) is not dict or not config["auth"]:
136
+ raise ValueError("auth must map principals to token environment variable names")
137
+ tokens = {}
138
+ for principal, variable in config["auth"].items():
139
+ token = env_value(variable) if variable.startswith("ANNO_") else os.environ.get(variable)
140
+ if not principal or not token or token in tokens:
141
+ raise ValueError("service authentication tokens must be present and unique")
142
+ tokens[token] = principal
143
+ return Releases(config["releases"], config["default_release"]), tokens