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,338 @@
1
+ """Chat completions adapter for generation and validated verbatim extraction."""
2
+
3
+ import json
4
+ import hashlib
5
+ import time
6
+
7
+ from annoscript._data import thaw, freeze
8
+ from annoscript._backend_profiles import validate_generation_profile, validate_extract_mode
9
+ from annoscript.anno import ExtractResponse, GenerateResponse, GenerateListResponse
10
+ from .base import (HTTPBackend, InferenceOutput, InferenceError, validate_response,
11
+ LLM_OPERATIONS, BatchResult, LLMBatchPlan, PreparedLLMRequest, encode_payload, normalize_call)
12
+ from ._generation_policy import validate_policy, PinnedTokenCounter, effective_budget, validate_token_ratio
13
+ from ._bounded_grammar import grammar as bounded_grammar, ensure_satisfiable
14
+ from ._shared_rules import factor_rules, validate_rule_dedup
15
+ from annoscript._request_adapters import DEFAULT_GENERATION_ADAPTER
16
+ from .adapters import generation_adapter
17
+ from ._preview import request_preview
18
+ from ._batch_wire import FORMAT, batch_calls, wire_name
19
+
20
+
21
+ def _unique_object(pairs):
22
+ value = {}
23
+ for key, item in pairs:
24
+ if key in value:
25
+ raise ValueError("duplicate JSON key")
26
+ value[key] = item
27
+ return value
28
+
29
+
30
+ class LLMAPIBackend(HTTPBackend):
31
+ supports_llm_batch = True
32
+ supports_prepared_batch = True
33
+
34
+ def __init__(self, *args, profile="legacy", request_adapter=DEFAULT_GENERATION_ADAPTER, extract_mode="validated", disable_thinking=True,
35
+ request_policy=None, assumed_avg_num_tokens_per_char=2, batch_rule_dedup=False, _token_counter=None, **kwargs):
36
+ if "source_layout" in kwargs:
37
+ raise TypeError("source_layout was removed; extract now uses source_field")
38
+ self._request_adapter = generation_adapter(request_adapter)
39
+ self.request_adapter = request_adapter
40
+ self.profile = validate_generation_profile(profile)
41
+ self.batch_rule_dedup = validate_rule_dedup(batch_rule_dedup, self.profile)
42
+ self.extract_mode = validate_extract_mode(extract_mode, self.profile)
43
+ if type(disable_thinking) is not bool:
44
+ raise ValueError("disable_thinking must be a boolean")
45
+ self.assumed_avg_num_tokens_per_char = validate_token_ratio(assumed_avg_num_tokens_per_char)
46
+ self.request_policy = validate_policy(request_policy)
47
+ if self.request_policy is not None and self.profile != "qwen_vllm":
48
+ raise ValueError("request_policy requires qwen_vllm")
49
+ self._token_counter = ((_token_counter or PinnedTokenCounter(self.request_policy["tokenizer"]))
50
+ if self.request_policy is not None else None)
51
+ super().__init__(*args, **kwargs)
52
+ self.disable_thinking = disable_thinking
53
+ self._last_batch = None
54
+ self.preparation_stats = dict(payload_builds=0, token_counts=0, encodes=0,
55
+ batch_plan_hits=0, grammar_seconds=0.0,
56
+ token_seconds=0.0, encoding_seconds=0.0)
57
+
58
+ def _sources_evidence(self, context, calls):
59
+ bindings = []
60
+ for call in calls:
61
+ if call.operation != "extract":
62
+ continue
63
+ resolved = normalize_call(call.id, call.operation, (), thaw(call.arguments), context)
64
+ if resolved.extraction_source != call.extraction_source:
65
+ raise ValueError("extract source_field binding differs from request state")
66
+ bindings.append({"call_id": call.id, "source_field": call.arguments["source_field"],
67
+ "source_sha256": hashlib.sha256(call.extraction_source.encode()).hexdigest()})
68
+ return {"source_layout": "source_field_v1", "source_bindings": bindings}
69
+
70
+ def _grammar(self, calls, *, batch=False):
71
+ started = time.perf_counter()
72
+ try:
73
+ return bounded_grammar(calls, batch=batch, extract_mode=self.extract_mode)
74
+ finally:
75
+ self.preparation_stats["grammar_seconds"] += time.perf_counter() - started
76
+
77
+ def _binding(self, context, calls, max_tokens, repair_feedback=None):
78
+ value = {"context_identity": id(context), "backend_identity": id(self),
79
+ "state": thaw(context.state), "system_prompt": context.system_prompt,
80
+ "calls": [{"id": c.id, "operation": c.operation, "arguments": thaw(c.arguments),
81
+ "extraction_source": c.extraction_source} for c in calls],
82
+ "model": self.model, "base_url": self.base_url, "profile": self.profile,
83
+ "request_adapter": self.request_adapter,
84
+ "extract_mode": self.extract_mode, "source_layout": "source_field_v1",
85
+ "batch_rule_dedup": self.batch_rule_dedup,
86
+ "batch_response_format": FORMAT,
87
+ "disable_thinking": self.disable_thinking, "request_policy": self.request_policy,
88
+ "token_ratio": self.assumed_avg_num_tokens_per_char,
89
+ "max_tokens": max_tokens, "repair_feedback": repair_feedback}
90
+ return hashlib.sha256(encode_payload(value)).hexdigest()
91
+
92
+ def _prepare(self, context, calls, max_tokens, *, batch=False, repair_feedback=None):
93
+ started = time.perf_counter()
94
+ before = dict(self.preparation_stats)
95
+ self.preparation_stats["payload_builds"] += 1
96
+ payload = (self._batch_payload(context, calls, max_tokens) if batch else
97
+ self._single_payload(context, calls[0], max_tokens, repair_feedback))
98
+ evidence = {"request_adapter": self.request_adapter, **self._context_evidence(payload), **self._sources_evidence(context, calls)}
99
+ if batch:
100
+ evidence.update(batch_response_format=FORMAT,
101
+ wire_call_ids={key: call.id for key, call in batch_calls(calls).items()})
102
+ elif calls[0].arguments.get("field_name") is not None:
103
+ evidence["wire_call_ids"] = {wire_name(calls[0], calls[0].id): calls[0].id}
104
+ if batch and self.batch_rule_dedup:
105
+ tasks = {c.id: {"task": {"instructions": c.arguments["instructions"]}} for c in calls}
106
+ _, rule_evidence = factor_rules(tasks)
107
+ evidence["shared_rules"] = rule_evidence
108
+ encoded_started = time.perf_counter()
109
+ body = encode_payload(payload)
110
+ self.preparation_stats["encodes"] += 1
111
+ self.preparation_stats["encoding_seconds"] += time.perf_counter() - encoded_started
112
+ binding = self._binding(context, calls, max_tokens, repair_feedback)
113
+ evidence["request_body_sha256"] = hashlib.sha256(body).hexdigest()
114
+ evidence["preparation"] = {k: v - before[k] for k, v in self.preparation_stats.items()}
115
+ evidence["preparation"]["seconds"] = time.perf_counter() - started
116
+ return PreparedLLMRequest(binding, freeze(payload), body, freeze(evidence))
117
+
118
+ def resolve_token_budget(self, call, fallback):
119
+ return effective_budget(call, self.assumed_avg_num_tokens_per_char)
120
+
121
+ def _context_evidence(self, payload):
122
+ if self.request_policy is None:
123
+ return {"assumed_avg_num_tokens_per_char": self.assumed_avg_num_tokens_per_char}
124
+ started = time.perf_counter()
125
+ try:
126
+ tokens = self._token_counter(payload)
127
+ finally:
128
+ self.preparation_stats["token_counts"] += 1
129
+ self.preparation_stats["token_seconds"] += time.perf_counter() - started
130
+ return {"assumed_avg_num_tokens_per_char": self.assumed_avg_num_tokens_per_char, "input_tokens": tokens, "output_budget": payload["max_tokens"],
131
+ "context_window": self.request_policy["context_window"],
132
+ "effective_sampling": self.request_policy["sampling"],
133
+ "tokenizer_revision": self.request_policy["tokenizer"]["revision"],
134
+ "grammar_sha256": hashlib.sha256(payload["structured_outputs"]["grammar"].encode()).hexdigest()}
135
+
136
+ def _check_context(self, payload, calls, *, repair=False, prepared=None):
137
+ evidence = thaw(prepared.evidence) if prepared is not None else self._context_evidence(payload)
138
+ if "input_tokens" in evidence and evidence["input_tokens"] + evidence["output_budget"] > evidence["context_window"]:
139
+ self._diagnose({"event": "local_rejection", "code": "context_length_exceeded",
140
+ "dispatch_status": "not_sent", "calls": [c.id for c in calls],
141
+ "repair": repair, **evidence})
142
+ raise InferenceError("input tokens plus output budget exceed context window",
143
+ code="context_length_exceeded", metadata={"dispatch_status": "not_sent", **evidence})
144
+ return evidence
145
+
146
+ async def _preflight(self, payload, calls, *, repair=False, prepared=None):
147
+ return self._check_context(payload, calls, repair=repair, prepared=prepared)
148
+
149
+ async def _check_constraints(self, calls, *, repair=False):
150
+ self._validate_constraints(calls, repair=repair)
151
+
152
+ def _validate_constraints(self, calls, *, repair=False):
153
+ for call in calls:
154
+ effective_budget(call, self.assumed_avg_num_tokens_per_char)
155
+ try:
156
+ ensure_satisfiable(call)
157
+ except InferenceError as exc:
158
+ self._diagnose({"event": "local_rejection", "code": exc.code,
159
+ "dispatch_status": "not_sent", "calls": [call.id], "repair": repair})
160
+ raise
161
+
162
+ def _single_payload(self, context, call, max_tokens, repair_feedback=None):
163
+ return self._request_adapter.single_payload(
164
+ context, call, model=self.model, profile=self.profile, disable_thinking=self.disable_thinking,
165
+ max_tokens=max_tokens, grammar=self._grammar, repair_feedback=repair_feedback,
166
+ sampling=self.request_policy["sampling"] if self.request_policy is not None else None)
167
+
168
+ def preview(self, context, call, *, repair_feedback=None, max_tokens_override=None):
169
+ """Preview one resolved call, with the same constraints and preflight as infer."""
170
+ if call.operation not in LLM_OPERATIONS:
171
+ raise ValueError("unsupported LLM operation")
172
+ self._validate_constraints([call], repair=repair_feedback is not None)
173
+ budget = effective_budget(call, self.assumed_avg_num_tokens_per_char, max_tokens_override)
174
+ return self._preview(context, [call], budget, repair_feedback=repair_feedback)
175
+
176
+ def preview_batch(self, context, calls, *, max_tokens):
177
+ """Preview an already selected batch; does not speculate or choose a path."""
178
+ batch_calls(calls)
179
+ self._validate_constraints(calls)
180
+ return self._preview(context, calls, max_tokens, batch=True)
181
+
182
+ def _preview(self, context, calls, max_tokens, *, batch=False, repair_feedback=None):
183
+ prepared = self._prepare(context, calls, max_tokens, batch=batch, repair_feedback=repair_feedback)
184
+ payload = thaw(prepared.payload)
185
+ evidence = self._check_context(payload, calls, repair=repair_feedback is not None, prepared=prepared)
186
+ evidence.pop("preparation", None)
187
+ return request_preview(payload, stage="generation", adapter=self.request_adapter,
188
+ url=self.base_url + "/chat/completions", evidence=evidence)
189
+
190
+ async def infer(self, context, call, *, repair_feedback=None, max_tokens_override=None):
191
+ if call.operation not in LLM_OPERATIONS:
192
+ raise ValueError("unsupported LLM operation")
193
+ await self._check_constraints([call], repair=repair_feedback is not None)
194
+ max_tokens = effective_budget(call, self.assumed_avg_num_tokens_per_char, max_tokens_override)
195
+ prepared = self._prepare(context, [call], max_tokens, repair_feedback=repair_feedback)
196
+ payload = thaw(prepared.payload)
197
+ evidence = await self._preflight(payload, [call], repair=repair_feedback is not None, prepared=prepared)
198
+ data, metadata = await self.post(self.base_url + "/chat/completions", payload,
199
+ diagnostic_context={"calls": [{"id": call.id, "operation": call.operation, "arguments": thaw(call.arguments)}],
200
+ "repair": repair_feedback is not None, **evidence}, _encoded_body=prepared.body)
201
+ try:
202
+ choice = data["choices"][0]
203
+ if choice["finish_reason"] != "stop":
204
+ raise InferenceError("generation did not complete normally", code="incomplete_generation", metadata=metadata)
205
+ message = choice["message"]
206
+ if message.get("refusal") or type(message["content"]) is not str:
207
+ raise ValueError()
208
+ content = message["content"]
209
+ if call.operation == "generate":
210
+ response = GenerateResponse(json.loads(content) if call.arguments.get("nullable", False) else content)
211
+ else:
212
+ value = json.loads(content)
213
+ if type(value) is not dict or set(value) != {"items"}:
214
+ raise ValueError()
215
+ cls = ExtractResponse if call.operation == "extract" else GenerateListResponse
216
+ response = cls(value["items"])
217
+ validate_response(call, response)
218
+ except InferenceError as exc:
219
+ exc.metadata = metadata
220
+ raise
221
+ except (KeyError, IndexError, TypeError, ValueError):
222
+ raise InferenceError("invalid LLM output", code="invalid_response", metadata=metadata) from None
223
+ metadata["advisory_lengths"] = {call.id: self._length_observation(call, response)}
224
+ self._diagnose({"event": "advisory_lengths", "request_id": metadata.get("request_id"),
225
+ "observations": metadata["advisory_lengths"]})
226
+ return InferenceOutput(response, metadata)
227
+
228
+ @staticmethod
229
+ def _length_observation(call, response):
230
+ a = call.arguments
231
+ single = call.operation == "generate"
232
+ lengths = ([] if response.text is None else [len(response.text)]) if single else [len(item) for item in response.items]
233
+ low = a.get("min_length" if single else "min_length_per_item", 0)
234
+ high = a["max_length" if single else "max_length_per_item"]
235
+ return {**({"is_null": response.text is None} if single and a.get("nullable", False) else {}),
236
+ "actual_codepoints": lengths, "suggested_min": low, "suggested_max": high,
237
+ "outside_suggestion": sum(n < low or n > high for n in lengths),
238
+ "hard_failure": False}
239
+
240
+ def _batch_payload(self, context, calls, max_tokens):
241
+ if (not calls or len({c.id for c in calls}) != len(calls)
242
+ or type(max_tokens) is not int or max_tokens < 1):
243
+ raise ValueError("a batch requires distinct calls and a positive token budget")
244
+ for call in calls:
245
+ if call.operation not in LLM_OPERATIONS:
246
+ raise ValueError("unsupported LLM operation")
247
+ effective_budget(call, self.assumed_avg_num_tokens_per_char)
248
+ return self._request_adapter.batch_payload(
249
+ context, calls, model=self.model, profile=self.profile, disable_thinking=self.disable_thinking,
250
+ max_tokens=max_tokens, grammar=self._grammar, rule_dedup=self.batch_rule_dedup,
251
+ sampling=self.request_policy["sampling"] if self.request_policy is not None else None)
252
+
253
+ def plan_batch(self, context, calls, *, max_calls, max_input_bytes, max_output_tokens, default_tokens):
254
+ """Choose a bounded prefix without network work; None means use single inference."""
255
+ if any(type(v) is not int or v < 1 for v in (max_calls, max_input_bytes, max_output_tokens, default_tokens)):
256
+ raise ValueError("invalid batch planning limits")
257
+ if len(calls) < 2 or max_calls < 2:
258
+ return None
259
+ key = (self._binding(context, calls, max_output_tokens), max_calls, max_input_bytes, default_tokens)
260
+ if self._last_batch is not None and self._last_batch[0] is context and self._last_batch[1] == key:
261
+ self.preparation_stats["batch_plan_hits"] += 1
262
+ return self._last_batch[2]
263
+ selected, budget, plan = [], 64, None
264
+ if self.request_policy is not None:
265
+ default_tokens = self.request_policy["default_max_tokens"]
266
+ for call in calls[:max_calls]:
267
+ budget += effective_budget(call, self.assumed_avg_num_tokens_per_char) + 32
268
+ if budget > max_output_tokens:
269
+ break
270
+ candidate = selected + [call]
271
+ try:
272
+ prepared = self._prepare(context, candidate, budget, batch=True)
273
+ except InferenceError:
274
+ break # The offending single call reports its own local error when dispatched.
275
+ size = len(prepared.body)
276
+ if size > max_input_bytes:
277
+ break
278
+ evidence = prepared.evidence
279
+ if "input_tokens" in evidence and evidence["input_tokens"] + budget > evidence["context_window"]:
280
+ break
281
+ selected = candidate
282
+ if len(selected) >= 2:
283
+ plan = LLMBatchPlan(tuple(selected), budget, size, prepared)
284
+ self._last_batch = (context, key, plan)
285
+ return plan
286
+
287
+ async def infer_batch(self, context, calls, *, max_tokens, prepared=None):
288
+ identified = batch_calls(calls)
289
+ await self._check_constraints(calls)
290
+ if prepared is None or prepared.binding != self._binding(context, calls, max_tokens):
291
+ prepared = self._prepare(context, calls, max_tokens, batch=True)
292
+ payload = thaw(prepared.payload)
293
+ evidence = await self._preflight(payload, calls, prepared=prepared)
294
+ data, metadata = await self.post(self.base_url + "/chat/completions", payload, logical_calls=len(calls),
295
+ diagnostic_context={"calls": [{"id": c.id, "operation": c.operation, "arguments": thaw(c.arguments)} for c in calls],
296
+ "repair": False, **evidence}, _encoded_body=prepared.body)
297
+ try:
298
+ choice = data["choices"][0]
299
+ if type(choice) is not dict:
300
+ raise ValueError()
301
+ if choice.get("finish_reason") == "content_filter":
302
+ raise InferenceError("model refused batch generation", code="model_refusal", metadata=metadata)
303
+ message = choice["message"]
304
+ if type(message) is not dict:
305
+ raise ValueError()
306
+ if message.get("refusal"):
307
+ raise InferenceError("model refused batch generation", code="model_refusal", metadata=metadata)
308
+ if choice["finish_reason"] == "length":
309
+ raise InferenceError("batch generation was truncated", code="incomplete_batch_generation", metadata=metadata)
310
+ if choice["finish_reason"] != "stop":
311
+ raise InferenceError("generation did not complete normally", code="incomplete_generation", metadata=metadata)
312
+ value = json.loads(message["content"], object_pairs_hook=_unique_object)
313
+ if type(value) is not dict or set(value) != {"answers"} or type(value["answers"]) is not dict:
314
+ raise ValueError()
315
+ answers = value["answers"]
316
+ if set(answers) - set(identified):
317
+ raise ValueError()
318
+ except InferenceError:
319
+ raise
320
+ except (KeyError, IndexError, TypeError, ValueError):
321
+ raise InferenceError("unreliable batch output mapping", code="invalid_batch_output", metadata=metadata) from None
322
+ responses, errors = {}, {}
323
+ for identity, call in identified.items():
324
+ try:
325
+ answer = answers[identity]
326
+ cls = {"generate": GenerateResponse, "generate_list": GenerateListResponse, "extract": ExtractResponse}[call.operation]
327
+ responses[call.id] = validate_response(call, cls(answer))
328
+ except InferenceError as exc:
329
+ exc.metadata = metadata
330
+ errors[call.id] = exc
331
+ except (KeyError, TypeError, ValueError):
332
+ errors[call.id] = InferenceError("missing or invalid batch answer", code="invalid_response",
333
+ metadata=metadata, call_id=call.id)
334
+ metadata["advisory_lengths"] = {c.id: self._length_observation(c, responses[c.id])
335
+ for c in calls if c.id in responses}
336
+ self._diagnose({"event": "advisory_lengths", "request_id": metadata.get("request_id"),
337
+ "observations": metadata["advisory_lengths"]})
338
+ return BatchResult(responses, metadata, errors)
annoscript/client.py ADDED
@@ -0,0 +1,247 @@
1
+ """Local or session-owned remote client, with compatible shared contract exports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from typing import List
7
+
8
+ from annoscript.anno import Anno
9
+ from annoscript._client_common import (
10
+ JSONValue, State, R, InputCheck, InputValidationError,
11
+ Result, ResultError, ResultStream, require, precondition, system_prompt,
12
+ get_program_config, validate_inputs, prepare_inputs,
13
+ )
14
+
15
+
16
+ class AnnoClient:
17
+ """Synchronous local Runtime owner or simplified remote job client.
18
+
19
+ Construction performs no network work. A private event-loop thread starts
20
+ on the first valid submission and is shared by this client's submissions.
21
+ The local client owns its runtime. ``remote()`` owns a remote job session.
22
+ Use a context manager or close() to release ownership explicitly.
23
+ """
24
+
25
+ @property
26
+ def closed(self):
27
+ return self._remote.closed if hasattr(self, "_remote") else self._closed
28
+
29
+ @closed.setter
30
+ def closed(self, value):
31
+ self._closed = value
32
+
33
+ def __init__(self, runtime, *, mode="eager"):
34
+ from annoscript.runtime import AnnoRuntime
35
+ if not isinstance(runtime, AnnoRuntime):
36
+ raise TypeError("runtime must be a AnnoRuntime")
37
+ if runtime._loop is not None or runtime.closed or getattr(runtime, "_client_owner", None) is not None:
38
+ raise ValueError("client requires an unstarted runtime")
39
+ if mode not in ("eager", "strict", "speculative"):
40
+ raise ValueError("mode must be eager, strict or speculative")
41
+ self.runtime, self.mode = runtime, mode
42
+ runtime._client_owner = self
43
+ self._loop = self._thread = None
44
+ self._streams = set()
45
+ self.closed = False
46
+
47
+ @classmethod
48
+ def remote(cls, base_url, auth_token=None, *, mode="eager", optimize=True,
49
+ timeout=30.0, transport=None):
50
+ """Use session-owned durable jobs with in-memory reconnect state.
51
+
52
+ Closing this client cancels unfinished remote jobs. A new process does
53
+ not recover this session; use AnnoJobClient for explicit durable recovery.
54
+ """
55
+ import weakref
56
+ from annoscript._remote_jobs import RemoteAdapter, _finalize
57
+ client = cls.__new__(cls)
58
+ client._remote = RemoteAdapter(base_url, auth_token, mode=mode, optimize=optimize,
59
+ timeout=timeout, transport=transport)
60
+ client.mode, client.closed = mode, False
61
+ client._finalizer = weakref.finalize(client, _finalize, client._remote.session)
62
+ return client
63
+
64
+ @classmethod
65
+ def from_env(cls, *, env_file=None, mode="eager", disable_thinking=True,
66
+ generation_profile=None, decision_request_adapter=None, generation_request_adapter=None,
67
+ decision_request_adapter_options=None,
68
+ batch_rule_dedup=False, assumed_avg_num_tokens_per_char=2,
69
+ timeout=60.0, **runtime_options):
70
+ if "source_layout" in runtime_options:
71
+ raise TypeError("source_layout was removed; extract now uses source_field")
72
+ from annoscript.backends.config import backends_from_env
73
+ from annoscript.runtime import AnnoRuntime
74
+ decision, llm = backends_from_env(env_file=env_file, disable_thinking=disable_thinking,
75
+ generation_profile=generation_profile, batch_rule_dedup=batch_rule_dedup, timeout=timeout,
76
+ decision_request_adapter=decision_request_adapter,
77
+ decision_request_adapter_options=decision_request_adapter_options,
78
+ generation_request_adapter=generation_request_adapter,
79
+ assumed_avg_num_tokens_per_char=assumed_avg_num_tokens_per_char)
80
+ return cls(AnnoRuntime(decision, llm, **runtime_options), mode=mode)
81
+
82
+ def _start(self):
83
+ if self._loop is None:
84
+ import asyncio
85
+ from threading import Thread, Event
86
+ self._loop = asyncio.new_event_loop()
87
+ started = Event()
88
+ def serve():
89
+ asyncio.set_event_loop(self._loop)
90
+ self._loop.call_soon(started.set)
91
+ self._loop.run_forever()
92
+ self._loop.close()
93
+ self._thread = Thread(target=serve, name="anno-runtime", daemon=True)
94
+ self._thread.start()
95
+ started.wait()
96
+
97
+ def _call(self, coroutine):
98
+ import asyncio
99
+ return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result()
100
+
101
+ def __enter__(self):
102
+ if self.closed:
103
+ raise RuntimeError("client is closed")
104
+ return self
105
+
106
+ def __exit__(self, *args):
107
+ if args and args[0] is not None:
108
+ try:
109
+ self.close()
110
+ except BaseException:
111
+ import logging
112
+ logging.getLogger(__name__).warning("Client cleanup failed while handling another exception")
113
+ else:
114
+ self.close()
115
+
116
+ def close(self):
117
+ if self.closed:
118
+ return
119
+ self.closed = True
120
+ if hasattr(self, "_remote"):
121
+ try:
122
+ self._remote.close()
123
+ finally:
124
+ self._finalizer.detach()
125
+ return
126
+ try:
127
+ for stream in list(self._streams):
128
+ stream.close()
129
+ if self._loop is not None:
130
+ self._call(self.runtime.aclose())
131
+ else:
132
+ import asyncio
133
+ asyncio.run(self.runtime.aclose())
134
+ finally:
135
+ if self._loop is not None:
136
+ self._loop.call_soon_threadsafe(self._loop.stop)
137
+ self._thread.join()
138
+
139
+ _get_program_config = staticmethod(get_program_config)
140
+ _validate_inputs = staticmethod(validate_inputs)
141
+
142
+ def submit(
143
+ self,
144
+ program: Callable[[Anno], R],
145
+ inputs: List[State],
146
+ *,
147
+ ordered: bool = False,
148
+ ) -> ResultStream[R]:
149
+ """Apply a single-input eDSL function to a list of input states.
150
+
151
+ For each input, supply ``program`` with an independent ``AnnoScript`` bound to
152
+ that state and the execution configuration. All AnnoScript operations in the
153
+ program must target this supplied instance; programs must not construct
154
+ additional AnnoScript instances or switch to derived contexts. Model execution
155
+ and constraint handling belong to the eDSL runtime.
156
+
157
+ Include ``_get_program_config(program)`` as remote program configuration.
158
+ The runtime supplies its ``system_prompt`` to each injected AnnoScript and uses
159
+ it for LLM calls, including those in helpers. Decision requests use their
160
+ own instructions and requirements. Keep the prompt separate from input
161
+ states and per-call instructions. The prompt text is transmitted;
162
+ the decorator declaration itself is not part of remote executable code.
163
+
164
+ ``inputs`` must be a list of strings, JSON arrays, or JSON objects.
165
+ An empty list produces an empty result stream. Input and output JSON
166
+ values must use string object keys and finite numbers.
167
+
168
+ Run all registered ``precondition`` checks locally with
169
+ ``_validate_inputs`` before any remote submission. Return failed inputs
170
+ as error results with code ``invalid_input`` and their original indices;
171
+ do not create a AnnoScript context or remote task for those inputs. Submit only
172
+ inputs that passed, and merge local errors into the same ResultStream,
173
+ respecting ``ordered``. An unexpected checker exception aborts the whole
174
+ submission before any remote work. Local check functions, rules, and
175
+ decorator declarations must not be included in the remote program.
176
+
177
+ Preserve each program return value in ``Result.value``, without encoding
178
+ it as a JSON string or flattening lists. Result indices are the zero-based
179
+ positions in ``inputs``; retries retain the same index. Normal completion
180
+ produces one success or error result for every input, without duplicate
181
+ delivery within the stream. Early closure or a submission-level failure
182
+ may leave inputs without delivered results.
183
+
184
+ By default deliver results as they become available. ``ordered=True``
185
+ delivers them in input order. Scheduling and concurrency are left to
186
+ concrete implementations.
187
+
188
+ Per-input execution failures are error results, so other inputs may
189
+ continue. Invalid submission configuration, program errors preventing
190
+ submission, authentication failures, and unrecoverable transport failures
191
+ raise exceptions.
192
+ Once a stream is returned, fatal iteration errors close it automatically.
193
+
194
+ Use the returned stream as a context manager to release resources even
195
+ when the consumer stops early or raises an exception.
196
+ """
197
+ if hasattr(self, "_remote"):
198
+ if self.closed:
199
+ raise RuntimeError("client is closed")
200
+ return self._remote.submit(program, inputs, ordered=ordered)
201
+ from annoscript.compiler import compile_program
202
+ from annoscript.plan_lowering import lower_to_plan
203
+ from annoscript.runtime import RunInput
204
+ if self.closed:
205
+ raise RuntimeError("client is closed")
206
+ prepared, errors = prepare_inputs(program, inputs)
207
+ valid = [RunInput(index, state) for index, state in prepared]
208
+ plan = lower_to_plan(compile_program(program))
209
+ self._start()
210
+ source = self._call(self.runtime.submit(plan, valid, mode=self.mode))
211
+ stream = _ClientStream(self, source, errors, ordered)
212
+ self._streams.add(stream)
213
+ return stream
214
+
215
+
216
+ class _ClientStream(ResultStream):
217
+ def __init__(self, client, source, errors, ordered):
218
+ super().__init__()
219
+ self.client, self.source, self.ordered = client, source, ordered
220
+ self.local = iter(Result(i, "error", error=e) for i, e in sorted(errors.items()))
221
+ self.buffer, self.index = {}, 0
222
+
223
+ def _pull(self):
224
+ try:
225
+ return next(self.local)
226
+ except StopIteration:
227
+ try:
228
+ return self.client._call(self.source.__anext__())
229
+ except StopAsyncIteration:
230
+ raise StopIteration from None
231
+
232
+ def _next_result(self):
233
+ if not self.ordered:
234
+ return self._pull()
235
+ while self.index not in self.buffer:
236
+ result = self._pull()
237
+ self.buffer[result.index] = result
238
+ result = self.buffer.pop(self.index)
239
+ self.index += 1
240
+ return result
241
+
242
+ def _close(self):
243
+ try:
244
+ self.client._call(self.source.aclose())
245
+ finally:
246
+ self.buffer.clear()
247
+ self.client._streams.discard(self)
@@ -0,0 +1,17 @@
1
+ """Client-only suite: python -m annoscript.client_tests [-q|-v] (Python 3.10+).
2
+
3
+ An explicit module list avoids importing Runtime/Backend test modules during
4
+ discovery. Plan tests remain part of the full server-side regression suite.
5
+ """
6
+
7
+ import unittest
8
+
9
+
10
+ def load_tests(loader, tests, pattern):
11
+ return loader.loadTestsFromNames([
12
+ "annoscript.test_client", "annoscript.test_compiler", "annoscript.test_ir", "annoscript.test_passes", "annoscript.test_examples",
13
+ "annoscript.test_release_client", "annoscript.test_metrics", "annoscript.test_formatter", "annoscript.test_remote", "annoscript.test_jobs", ])
14
+
15
+
16
+ if __name__ == "__main__":
17
+ unittest.main()