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,427 @@
1
+ """Inference contracts, validation and shared asynchronous HTTP transport."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from collections.abc import Mapping
5
+ import inspect
6
+ import json
7
+ import math
8
+ import time
9
+ import uuid
10
+
11
+ from annoscript._data import freeze, thaw, snapshot
12
+ from annoscript.anno import (Example, Anno, NoulResponse, ChoiceResponse, ScoreResponse,
13
+ ExtractResponse, GenerateResponse, GenerateListResponse)
14
+
15
+
16
+ DECISION_OPERATIONS = frozenset(("noul", "choice", "score"))
17
+ LLM_OPERATIONS = frozenset(("extract", "generate", "generate_list"))
18
+ LLM_BATCH_DEFAULTS = {
19
+ "max_llm_batch_calls": 8,
20
+ "max_llm_batch_input_bytes": 262144,
21
+ "max_llm_batch_output_tokens": 16384,
22
+ "llm_batch_default_tokens": 1024,
23
+ "llm_batch_repair_attempts": 1,
24
+ }
25
+ RESPONSE_TYPES = dict(zip(
26
+ ("noul", "choice", "score", "extract", "generate", "generate_list"),
27
+ (NoulResponse, ChoiceResponse, ScoreResponse, ExtractResponse, GenerateResponse, GenerateListResponse),
28
+ ))
29
+
30
+
31
+ class InferenceError(Exception):
32
+ """A request-local model or response failure, safe for user diagnostics."""
33
+ def __init__(self, message, *, code="inference_failed", metadata=None, call_id=None):
34
+ super().__init__(message)
35
+ self.code = code
36
+ self.metadata = metadata or {}
37
+ self.call_id = call_id
38
+
39
+
40
+ class BackendFatalError(InferenceError):
41
+ """Configuration/authentication/connectivity failure of a submission."""
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class InferenceContext:
46
+ state: object
47
+ system_prompt: str | None = None
48
+
49
+ def __post_init__(self):
50
+ object.__setattr__(self, "state", freeze(snapshot(thaw(self.state), state=True)))
51
+ if self.system_prompt is not None and type(self.system_prompt) is not str:
52
+ raise ValueError("system_prompt must be a string")
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class InferenceCall:
57
+ id: str
58
+ operation: str
59
+ arguments: Mapping
60
+ # Resolved at normalization, never included in public arguments or messages.
61
+ extraction_source: str | None = field(default=None, repr=False)
62
+
63
+ def __post_init__(self):
64
+ object.__setattr__(self, "arguments", freeze(snapshot(dict(self.arguments))))
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class InferenceOutput:
69
+ response: object
70
+ metadata: dict = field(default_factory=dict)
71
+
72
+
73
+ @dataclass(frozen=True)
74
+ class BatchResult:
75
+ responses: Mapping[str, object]
76
+ metadata: dict = field(default_factory=dict)
77
+ errors: Mapping[str, InferenceError] = field(default_factory=dict)
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class PreparedLLMRequest:
82
+ """Internal, attempt-local immutable transport and preflight snapshot."""
83
+ binding: str
84
+ payload: object
85
+ body: bytes
86
+ evidence: object
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class PreparedDecisionRequest:
91
+ binding: str
92
+ payload: object
93
+ body: bytes
94
+ evidence: object
95
+
96
+
97
+ @dataclass(frozen=True)
98
+ class DecisionBatchPlan:
99
+ calls: tuple
100
+ input_bytes: int | None
101
+ prepared: PreparedDecisionRequest | None = field(default=None, repr=False, compare=False)
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class LLMBatchPlan:
106
+ calls: tuple
107
+ max_tokens: int
108
+ input_bytes: int
109
+ prepared: PreparedLLMRequest | None = field(default=None, repr=False, compare=False)
110
+
111
+
112
+ def encode_payload(payload):
113
+ """The exact JSON encoding used at the HTTP boundary and by batch planning."""
114
+ return json.dumps(payload, ensure_ascii=False, allow_nan=False,
115
+ separators=(",", ":")).encode("utf-8")
116
+
117
+
118
+ def validate_batch_options(options):
119
+ for name, value in options.items():
120
+ if type(value) is not int or (value not in (0, 1) if name == "llm_batch_repair_attempts" else value < 1):
121
+ raise ValueError(f"invalid {name}")
122
+
123
+
124
+ def normalize_call(call_id, operation, args, kwargs, context):
125
+ values = normalize_arguments(operation, args, kwargs)
126
+ source = None
127
+ if operation == "extract":
128
+ source = extraction_source(context.state, values["source_field"])
129
+ return InferenceCall(call_id, operation, values, source)
130
+
131
+
132
+ def extraction_source(state, name):
133
+ if type(state) is str:
134
+ if name is not None:
135
+ raise ValueError("string state requires source_field=None")
136
+ return state
137
+ if not isinstance(state, Mapping) or name is None:
138
+ raise ValueError("object state requires an explicit extract source_field")
139
+ if name not in state or type(state[name]) is not str:
140
+ raise ValueError("extract source_field must name an existing string field")
141
+ return state[name]
142
+
143
+
144
+ def normalize_examples(operation, values):
145
+ examples = values.pop("examples")
146
+ if examples is None:
147
+ return
148
+ if type(examples) is not list:
149
+ raise ValueError("examples must be a list")
150
+ normalized = []
151
+ for index, example in enumerate(examples):
152
+ if type(example) is Example:
153
+ example = example.to_dict()
154
+ if type(example) is not dict or set(example) != {"state", "answer"}:
155
+ raise ValueError(f"examples[{index}] requires exactly state and answer")
156
+ example = snapshot(example)
157
+ snapshot(example["state"], state=True)
158
+ answer = example["answer"]
159
+ if operation == "choice":
160
+ valid = type(answer) is str and answer in values["criteria"]
161
+ elif operation == "noul":
162
+ valid = type(answer) is bool
163
+ elif operation == "score":
164
+ valid = type(answer) is int and 0 <= answer < len(values["criteria"])
165
+ else:
166
+ source = (extraction_source(example["state"], values["source_field"])
167
+ if operation == "extract" else None)
168
+ call = InferenceCall(f"example-{index}", operation, values, source)
169
+ try:
170
+ validate_response(call, RESPONSE_TYPES[operation](answer))
171
+ valid = True
172
+ except InferenceError:
173
+ valid = False
174
+ if not valid:
175
+ raise ValueError(f"examples[{index}].answer violates {operation} contract")
176
+ normalized.append(example)
177
+ if normalized:
178
+ values["examples"] = normalized
179
+
180
+
181
+ def normalize_arguments(operation, args, kwargs):
182
+ """Validate public arguments without manufacturing a context for budget checks."""
183
+ if operation not in RESPONSE_TYPES:
184
+ raise ValueError("unsupported inference operation")
185
+ if operation == "extract" and "source" in kwargs:
186
+ raise ValueError("extract source was removed; use source_field with state")
187
+ bound = inspect.signature(getattr(Anno, operation)).bind(None, *args, **kwargs)
188
+ bound.apply_defaults()
189
+ values = {k: v for k, v in bound.arguments.items() if k != "self"}
190
+ if operation == "generate":
191
+ if type(values["nullable"]) is not bool:
192
+ raise ValueError("nullable must be a boolean")
193
+ if not values["nullable"]:
194
+ # Keep default calls and their cache bindings unchanged.
195
+ values.pop("nullable")
196
+ if "field_name" in values:
197
+ name = values["field_name"]
198
+ if name is None:
199
+ # Preserve the normalized arguments of existing, unnamed programs.
200
+ values.pop("field_name")
201
+ elif type(name) is not str or not name.strip():
202
+ raise ValueError("field_name must be a nonblank string or None")
203
+ if type(values["instructions"]) is not str:
204
+ raise ValueError("instructions must be a string")
205
+ requirements = values.pop("requirements")
206
+ if requirements is not None:
207
+ if type(requirements) is not list or any(
208
+ type(value) is not str or not value.strip() for value in requirements
209
+ ):
210
+ raise ValueError("requirements must be a list of nonblank strings or None")
211
+ if requirements:
212
+ values["requirements"] = requirements
213
+ if operation == "noul":
214
+ for key in ("criteria_false", "criteria_true"):
215
+ if values[key] is not None and type(values[key]) is not str:
216
+ raise ValueError(f"{key} must be a string or None")
217
+ if operation == "choice":
218
+ criteria = values["criteria"]
219
+ if type(criteria) is not dict or not criteria or any(type(k) is not str or type(v) is not str for k, v in criteria.items()):
220
+ raise ValueError("choice criteria must be a nonempty string mapping")
221
+ if operation == "score":
222
+ if type(values["criteria"]) is not list or not values["criteria"] or any(type(v) is not str for v in values["criteria"]):
223
+ raise ValueError("score criteria must be a nonempty list of strings")
224
+ for key, value in values.items():
225
+ if key.startswith(("min_", "max_")):
226
+ optional = key == "max_num_tokens"
227
+ positive = key == "max_num_tokens" or (key == "max_length_per_item" and operation == "extract")
228
+ if value is None and optional:
229
+ continue
230
+ if type(value) is not int or value < (1 if positive else 0):
231
+ raise ValueError(f"invalid {key}")
232
+ for low, high in (("min_items", "max_items"), ("min_length", "max_length"), ("min_length_per_item", "max_length_per_item")):
233
+ if low in values and values.get(high) is not None and values[low] > values[high]:
234
+ raise ValueError(f"{low} exceeds {high}")
235
+ if operation == "extract":
236
+ if values["source_field"] is not None and type(values["source_field"]) is not str:
237
+ raise ValueError("extract source_field must be a string or None")
238
+ normalize_examples(operation, values)
239
+ return values
240
+
241
+
242
+ def validate_response(call, response):
243
+ a, op = call.arguments, call.operation
244
+ def require(condition, message):
245
+ if not condition:
246
+ raise InferenceError(message, code="invalid_response", call_id=call.id)
247
+ def number(x, low=None, high=None):
248
+ return type(x) in (int, float) and (type(x) is int or math.isfinite(x)) and (low is None or x >= low) and (high is None or x <= high)
249
+ def length(value, minimum, maximum):
250
+ return len(value) >= minimum and (maximum is None or len(value) <= maximum)
251
+ require(type(response) is RESPONSE_TYPES[op], "incorrect response type")
252
+ if op == "noul":
253
+ require(number(response.noul, 0, 1), "invalid noul probability")
254
+ elif op in ("choice", "score"):
255
+ keys = set(a["criteria"]) if op == "choice" else set(range(len(a["criteria"])))
256
+ require(type(response.probabilities) is dict and set(response.probabilities) == keys and
257
+ all(type(k) is (str if op == "choice" else int) for k in response.probabilities) and
258
+ all(number(v, 0, 1) for v in response.probabilities.values()), "invalid probabilities")
259
+ require(number(response.confidence, 0, 1), "invalid confidence")
260
+ if op == "choice":
261
+ require(type(response.choice) is str and response.choice in keys, "choice is outside criteria")
262
+ else:
263
+ require(number(response.score, 0, len(keys)-1), "invalid score")
264
+ require(response.legend == dict(enumerate(a["criteria"])), "incorrect score legend")
265
+ elif op == "generate":
266
+ require((response.text is None and a.get("nullable", False)) or
267
+ (type(response.text) is str and (not a["min_length"] or bool(response.text))),
268
+ "generated text must satisfy its string/null and nonempty contract" if a.get("nullable", False) else
269
+ "generated text must be a string and satisfy nonempty requirement")
270
+ else:
271
+ require(type(response.items) is list and all(type(x) is str for x in response.items), "items must be a list of strings")
272
+ require(length(response.items, a["min_items"], a["max_items"]), "items violate count limits")
273
+ for item in response.items:
274
+ if op == "extract":
275
+ require(type(call.extraction_source) is str and bool(item) and item in call.extraction_source,
276
+ "extraction is not a nonempty verbatim source substring")
277
+ else:
278
+ require(not a["min_length_per_item"] or bool(item), "generated item violates nonempty requirement")
279
+ return response
280
+
281
+
282
+ class HTTPBackend:
283
+ def __init__(self, base_url, auth_token, model, *, timeout=60.0, transport=None,
284
+ diagnostic_observer=None):
285
+ import httpx
286
+ url = httpx.URL(base_url)
287
+ if url.scheme not in ("http", "https") or not url.host or url.userinfo or url.query or url.fragment:
288
+ raise ValueError("backend URL must be an HTTP(S) address without credentials, query or fragment")
289
+ if type(model) is not str or not model or type(auth_token) is not str or not auth_token:
290
+ raise ValueError("model and authentication token are required")
291
+ if type(timeout) not in (int, float) or not math.isfinite(timeout) or timeout <= 0:
292
+ raise ValueError("timeout must be finite and positive")
293
+ self.base_url, self.model = base_url.rstrip("/"), model
294
+ self._token, self.timeout, self._transport = auth_token, timeout, transport
295
+ self._client = None
296
+ self.diagnostic_observer = diagnostic_observer
297
+ self._diagnostic_calls = {}
298
+
299
+ def _diagnose(self, event):
300
+ if self.diagnostic_observer is not None:
301
+ try:
302
+ self.diagnostic_observer({"model": self.model, **event})
303
+ except Exception:
304
+ raise BackendFatalError("diagnostic persistence failed", code="diagnostics_failed") from None
305
+
306
+ async def post(self, url, payload, *, logical_calls=None, diagnostic_context=None, _encoded_body=None):
307
+ guard = getattr(self, "_dispatch_guard", None)
308
+ if guard is not None:
309
+ guard()
310
+ body_args = {"_encoded_body": _encoded_body} if _encoded_body is not None else {}
311
+ meter = getattr(self, "_request_meter", None)
312
+ observing = self.diagnostic_observer is not None
313
+ if meter is None and not observing:
314
+ return await self._post(url, payload, **body_args)
315
+ questions = logical_calls if logical_calls is not None else len(payload["questions"]) if "questions" in payload else 1
316
+ identity = await meter.start(questions) if meter is not None else uuid.uuid4().hex
317
+ timing, metadata = {}, {}
318
+ diagnostic = {"request_id": identity} if observing else None
319
+ try:
320
+ if observing:
321
+ context = dict(diagnostic_context or {})
322
+ calls = context.get("calls", [])
323
+ parents = {self._diagnostic_calls[c["id"]] for c in calls
324
+ if context.get("repair") and c["id"] in self._diagnostic_calls}
325
+ self._diagnose({"event": "request", "request_id": identity, "started_unix": time.time(),
326
+ "body": _encoded_body if _encoded_body is not None else encode_payload(payload), "questions": questions,
327
+ "repair_parent_request_ids": sorted(parents), **context})
328
+ for call in calls:
329
+ self._diagnostic_calls[call["id"]] = identity
330
+ data, metadata = await self._post(url, payload, _timing=timing, _diagnostic=diagnostic, **body_args)
331
+ if observing:
332
+ metadata["request_id"] = identity
333
+ return data, metadata
334
+ except InferenceError as exc:
335
+ metadata = exc.metadata
336
+ raise
337
+ finally:
338
+ # Timing covers the HTTP await, not lazy client construction, JSON
339
+ # parsing or metric persistence. No finished metric is invented if
340
+ # execution never reached the transport boundary.
341
+ if "seconds" in timing:
342
+ try:
343
+ if meter is not None:
344
+ await meter.finish(identity, timing["seconds"], metadata.get("http_status", timing.get("http_status")), metadata.get("usage"))
345
+ finally:
346
+ if observing:
347
+ self._diagnose({"event": "outcome", "request_id": identity,
348
+ "seconds": timing["seconds"], "finished_unix": time.time(),
349
+ "http_status": metadata.get("http_status", timing.get("http_status")),
350
+ "usage": metadata.get("usage"),
351
+ "response_received": diagnostic.get("response_received", False)})
352
+ if observing and diagnostic.get("write_error"):
353
+ raise BackendFatalError("diagnostic persistence failed", code="diagnostics_failed") from None
354
+
355
+ async def _post(self, url, payload, *, _timing=None, _diagnostic=None, _encoded_body=None):
356
+ import asyncio
357
+ import httpx
358
+ if self._client is None:
359
+ auth = self._token if self._token.lower().startswith("bearer ") else "Bearer " + self._token
360
+ self._client = httpx.AsyncClient(headers={"Authorization": auth},
361
+ timeout=self.timeout, transport=self._transport or httpx.AsyncHTTPTransport(retries=0))
362
+ guard = getattr(self, "_dispatch_guard", None)
363
+ if guard is not None:
364
+ guard()
365
+ try:
366
+ clock = getattr(getattr(self, "_request_meter", None), "clock", time.monotonic)
367
+ started = clock() if _timing is not None else None
368
+ try:
369
+ async with asyncio.timeout(self.timeout):
370
+ response = await self._client.post(url, content=_encoded_body if _encoded_body is not None else encode_payload(payload),
371
+ headers={"Content-Type": "application/json"})
372
+ if _timing is not None:
373
+ _timing["http_status"] = response.status_code
374
+ finally:
375
+ if _timing is not None:
376
+ _timing["seconds"] = max(0.0, clock() - started)
377
+ except (httpx.ConnectError, httpx.ConnectTimeout) as exc:
378
+ raise BackendFatalError("backend connection failed", code="backend_unavailable") from None
379
+ except (TimeoutError, httpx.TimeoutException):
380
+ raise InferenceError("model request timed out", code="model_timeout") from None
381
+ except httpx.RequestError:
382
+ raise InferenceError("model transport failed", code="transport_error") from None
383
+ if _diagnostic is not None:
384
+ _diagnostic["response_received"] = True
385
+ try:
386
+ self._diagnose({"event": "response", "request_id": _diagnostic["request_id"],
387
+ "finished_unix": time.time(), "http_status": response.status_code,
388
+ "body": response.content, "complete": True})
389
+ except BackendFatalError:
390
+ # Preserve numeric usage and HTTP status even if the raw sink fails.
391
+ _diagnostic["write_error"] = True
392
+ metadata = {"http_status": response.status_code}
393
+ if "Retry-After" in response.headers:
394
+ metadata["retry_after"] = response.headers["Retry-After"]
395
+ try:
396
+ data = json.loads(response.content, parse_constant=lambda _: (_ for _ in ()).throw(ValueError()))
397
+ if type(data) is not dict:
398
+ data = None
399
+ except (ValueError, UnicodeError):
400
+ data = None
401
+ # Numerical usage only: no provider body, prompts or reasoning text.
402
+ def usage(value):
403
+ if type(value) is dict:
404
+ return {k: usage(v) for k, v in value.items() if type(v) in (dict, int, float)}
405
+ return value if type(value) is int or math.isfinite(value) else None
406
+ if data is not None and (response.status_code < 400 or type(data.get("usage")) is dict):
407
+ metadata["usage"] = usage(data.get("usage", {})) if type(data.get("usage")) is dict else {}
408
+ if response.status_code in (401, 403):
409
+ raise BackendFatalError("backend authentication failed", code="authentication_failed", metadata=metadata)
410
+ if response.status_code >= 400:
411
+ error = data.get("error") if isinstance(data, dict) else None
412
+ message = error.get("message") if isinstance(error, dict) else None
413
+ if response.status_code == 400 and isinstance(message, str) and (
414
+ "System message must be at the beginning." in message):
415
+ # Recognize a deployment protocol error without logging the
416
+ # provider body. Context/input errors remain request-local.
417
+ raise BackendFatalError("backend chat template rejected system message layout",
418
+ code="backend_protocol_error", metadata=metadata)
419
+ raise InferenceError(f"model HTTP error {response.status_code}", code="model_http_error", metadata=metadata)
420
+ if data is None:
421
+ raise InferenceError("backend returned invalid JSON", code="invalid_response", metadata=metadata)
422
+ return data, metadata
423
+
424
+ async def aclose(self):
425
+ if self._client is not None:
426
+ await self._client.aclose()
427
+ self._client = None
@@ -0,0 +1,67 @@
1
+ """Explicit environment configuration; credential files are data, not shell code."""
2
+
3
+ import os
4
+ import re
5
+ import shlex
6
+ from pathlib import Path
7
+ from annoscript._env import normalize
8
+
9
+ from annoscript._backend_profiles import validate_generation_profile, validate_extract_mode
10
+ from annoscript._request_adapters import (DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER,
11
+ validate_request_adapter, normalize_decision_options)
12
+ from .decision import DecisionAPIBackend
13
+ from .llm import LLMAPIBackend
14
+
15
+
16
+ def backends_from_env(*, env_file=None, disable_thinking=True, generation_profile=None,
17
+ decision_request_adapter=None, generation_request_adapter=None,
18
+ decision_request_adapter_options=None,
19
+ extract_mode=None, batch_rule_dedup=False, request_policy=None,
20
+ assumed_avg_num_tokens_per_char=2, timeout=60.0):
21
+ values = {}
22
+ if env_file is not None:
23
+ for number, line in enumerate(Path(env_file).read_text(encoding="utf-8").splitlines(), 1):
24
+ if not line.strip() or line.lstrip().startswith("#"):
25
+ continue
26
+ match = re.fullmatch(r"\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)", line)
27
+ if not match:
28
+ raise ValueError(f"invalid environment assignment at line {number}")
29
+ try:
30
+ parts = shlex.split(match[2], comments=True)
31
+ except ValueError:
32
+ raise ValueError(f"invalid environment quoting at line {number}") from None
33
+ if len(parts) > 1:
34
+ raise ValueError(f"invalid environment value at line {number}")
35
+ values[match[1]] = parts[0] if parts else ""
36
+ values = normalize(values)
37
+ values.update(normalize(os.environ))
38
+ profile = validate_generation_profile(
39
+ generation_profile if generation_profile is not None else values.get("ANNO_GENERATE_PROFILE", "legacy")
40
+ )
41
+ mode = validate_extract_mode(extract_mode if extract_mode is not None
42
+ else values.get("ANNO_GENERATE_EXTRACT_MODE", "validated"), profile)
43
+ decision_recipe = validate_request_adapter(
44
+ decision_request_adapter if decision_request_adapter is not None else
45
+ values.get("ANNO_DECISION_REQUEST_ADAPTER", DEFAULT_DECISION_ADAPTER), "decision")
46
+ generation_recipe = validate_request_adapter(
47
+ generation_request_adapter if generation_request_adapter is not None else
48
+ values.get("ANNO_GENERATE_REQUEST_ADAPTER", DEFAULT_GENERATION_ADAPTER), "generation")
49
+ if decision_request_adapter_options is None and "ANNO_DECISION_REQUEST_ADAPTER_OPTIONS" in values:
50
+ from annoscript.job_protocol import decode
51
+ from annoscript.remote import RemoteProtocolError
52
+ try:
53
+ decision_request_adapter_options = decode(values["ANNO_DECISION_REQUEST_ADAPTER_OPTIONS"])
54
+ if type(decision_request_adapter_options) is not dict:
55
+ raise ValueError("request_adapter_options must be an object")
56
+ except RemoteProtocolError:
57
+ raise ValueError("invalid ANNO_DECISION_REQUEST_ADAPTER_OPTIONS JSON") from None
58
+ decision_options = normalize_decision_options(decision_recipe, decision_request_adapter_options)
59
+ def config(prefix):
60
+ names = [f"ANNO_{prefix}_{key}" for key in ("BASE_URL", "AUTH_TOKEN", "MODEL")]
61
+ for name in names:
62
+ if not values.get(name):
63
+ raise ValueError(f"missing environment variable {name}")
64
+ return [values[name] for name in names]
65
+ return (DecisionAPIBackend(*config("DECISION"), timeout=timeout, request_adapter=decision_recipe,
66
+ request_adapter_options=decision_options),
67
+ LLMAPIBackend(*config("GENERATE"), timeout=timeout, profile=profile, request_adapter=generation_recipe, extract_mode=mode, batch_rule_dedup=batch_rule_dedup, disable_thinking=disable_thinking, request_policy=request_policy, assumed_avg_num_tokens_per_char=assumed_avg_num_tokens_per_char))
@@ -0,0 +1,150 @@
1
+ """Decision service: one physical request per compatible, identified batch."""
2
+
3
+ import hashlib
4
+
5
+ from annoscript._data import thaw, freeze
6
+ from annoscript.anno import NoulResponse, ChoiceResponse, ScoreResponse
7
+ from .base import (HTTPBackend, BatchResult, InferenceError, validate_response, encode_payload,
8
+ DecisionBatchPlan, PreparedDecisionRequest)
9
+ from annoscript._request_adapters import DEFAULT_DECISION_ADAPTER
10
+ from .adapters import decision_adapter
11
+ from .adapters.decision import identified_calls as _identified_calls
12
+ from ._preview import request_preview
13
+
14
+
15
+ class DecisionAPIBackend(HTTPBackend):
16
+ supports_partial_answers = True
17
+ supports_decision_batch_planning = True
18
+ supports_prepared_batch = True
19
+
20
+ def __init__(self, *args, request_adapter=DEFAULT_DECISION_ADAPTER, request_adapter_options=None, **kwargs):
21
+ self._request_adapter = decision_adapter(request_adapter, request_adapter_options)
22
+ super().__init__(*args, **kwargs)
23
+
24
+ @property
25
+ def request_adapter(self):
26
+ return self._request_adapter.name
27
+
28
+ @property
29
+ def request_adapter_options(self):
30
+ return self._request_adapter.options
31
+
32
+ def _batch_payload(self, context, calls):
33
+ return self._request_adapter.batch_payload(context, calls, model=self.model)
34
+
35
+ def _binding(self, context, calls):
36
+ value = {"context_identity": id(context), "backend_identity": id(self), "model": self.model,
37
+ "base_url": self.base_url, "state": thaw(context.state),
38
+ "request_adapter": self.request_adapter, "request_adapter_options": thaw(self.request_adapter_options),
39
+ "calls": [{"id": c.id, "operation": c.operation, "arguments": thaw(c.arguments)} for c in calls]}
40
+ return hashlib.sha256(encode_payload(value)).hexdigest()
41
+
42
+ def _prepare(self, context, calls):
43
+ payload = self._batch_payload(context, calls)
44
+ body = encode_payload(payload)
45
+ evidence = {"request_adapter": self.request_adapter,
46
+ "request_adapter_options": thaw(self.request_adapter_options),
47
+ "wire_call_ids": {name: c.id for name, c in _identified_calls(calls).items()},
48
+ "task_recipes": {c.id: thaw(self._request_adapter.recipe(c)) for c in calls},
49
+ "state_sha256": hashlib.sha256(encode_payload(payload["state"])).hexdigest(),
50
+ "request_body_sha256": hashlib.sha256(body).hexdigest()}
51
+ return PreparedDecisionRequest(self._binding(context, calls), freeze(payload), body, freeze(evidence))
52
+
53
+ def plan_batch(self, context, calls, *, max_calls):
54
+ """Select an ordered compatible prefix; local call errors are deferred to infer."""
55
+ if type(max_calls) is not int or max_calls < 1 or not calls:
56
+ raise ValueError("Decision planning needs calls and a positive max_calls")
57
+ selected, key = [], None
58
+ for call in calls[:max_calls]:
59
+ try:
60
+ state, _ = self._request_adapter.render(context, call)
61
+ current = encode_payload(state)
62
+ except (InferenceError, ValueError, TypeError):
63
+ # Keep a malformed speculative call isolated. Its error is only
64
+ # observed if adopted, through the regular infer/receive path.
65
+ if not selected:
66
+ selected.append(call)
67
+ break
68
+ if selected and key != current:
69
+ break
70
+ selected.append(call)
71
+ key = current
72
+ try:
73
+ prepared = self._prepare(context, selected)
74
+ except (InferenceError, ValueError, TypeError):
75
+ # E.g. duplicate wire names retain the old batch failure instead of
76
+ # being silently split, renamed or raised by the scheduler itself.
77
+ prepared = None
78
+ return DecisionBatchPlan(tuple(selected), len(prepared.body) if prepared else None, prepared)
79
+
80
+ def _preview(self, context, calls, prepared=None):
81
+ if prepared is None or prepared.binding != self._binding(context, calls):
82
+ prepared = self._prepare(context, calls)
83
+ return request_preview(thaw(prepared.payload), stage="decision", adapter=self.request_adapter,
84
+ url=self.base_url, evidence=thaw(prepared.evidence))
85
+
86
+ def preview_batch(self, context, calls):
87
+ """Preview one physical batch; incompatible views fail without HTTP."""
88
+ return self._preview(context, calls)
89
+
90
+ def preview_batches(self, context, calls, *, max_calls=32):
91
+ """Preview all physical prefixes through the same planner as Runtime."""
92
+ if type(max_calls) is not int or max_calls < 1:
93
+ raise ValueError("max_calls must be a positive integer")
94
+ if not calls or len({c.id for c in calls}) != len(calls):
95
+ raise ValueError("a preview plan requires distinct calls")
96
+ remaining, requests = list(calls), []
97
+ while remaining:
98
+ plan = self.plan_batch(context, remaining, max_calls=max_calls)
99
+ requests.append(self._preview(context, plan.calls, plan.prepared))
100
+ remaining = remaining[len(plan.calls):]
101
+ return {"format": "lev-request-preview-plan", "version": 1, "stage": "decision",
102
+ "request_adapter": self.request_adapter, "request_adapter_options": thaw(self.request_adapter_options),
103
+ "logical_calls": [c.id for c in calls], "physical_requests": len(requests), "requests": requests}
104
+
105
+ async def infer_batch(self, context, calls, *, allow_partial=False, prepared=None):
106
+ identified = _identified_calls(calls)
107
+ if prepared is None or prepared.binding != self._binding(context, calls):
108
+ prepared = self._prepare(context, calls)
109
+ payload = thaw(prepared.payload)
110
+ questions = payload["questions"]
111
+ data, metadata = await self.post(self.base_url, payload,
112
+ diagnostic_context={"calls": [{"id": c.id, "operation": c.operation, "arguments": thaw(c.arguments)} for c in calls],
113
+ **thaw(prepared.evidence)}, _encoded_body=prepared.body)
114
+ current_call = None
115
+ try:
116
+ answers = data["answers"]
117
+ if (type(answers) is not dict or set(answers) - set(questions)
118
+ or not allow_partial and set(answers) != set(questions)):
119
+ raise ValueError()
120
+ results, errors = {}, {}
121
+ for name, call in identified.items():
122
+ current_call = call.id
123
+ try:
124
+ answer = answers[name]
125
+ if answer["type"] != call.operation:
126
+ raise ValueError()
127
+ if call.operation == "noul":
128
+ response = NoulResponse(answer["noul"])
129
+ elif call.operation == "choice":
130
+ response = ChoiceResponse(answer["choice"], answer["confidence"], answer["probabilities"])
131
+ else:
132
+ def indexed(mapping):
133
+ if type(mapping) is not dict or any(type(k) is not str or str(int(k)) != k for k in mapping):
134
+ raise ValueError()
135
+ return {int(k): v for k, v in mapping.items()}
136
+ legend = indexed(answer["legend"])
137
+ # Jev can echo the structured levels; expose the original
138
+ # string legend while still rejecting mismatched levels.
139
+ if legend == dict(enumerate(questions[name]["criteria"])):
140
+ legend = dict(enumerate(call.arguments["criteria"]))
141
+ response = ScoreResponse(indexed(answer["probabilities"]), answer["score"], legend, answer["confidence"])
142
+ results[call.id] = validate_response(call, response)
143
+ except (KeyError, TypeError, ValueError, InferenceError):
144
+ if not allow_partial:
145
+ raise
146
+ errors[call.id] = InferenceError("invalid Decision answer", code="invalid_response",
147
+ metadata=metadata, call_id=call.id)
148
+ except (KeyError, TypeError, ValueError, InferenceError):
149
+ raise InferenceError("invalid Decision answers", code="invalid_response", metadata=metadata, call_id=current_call) from None
150
+ return BatchResult(results, metadata, errors)