annoscript 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- annoscript/__init__.py +1 -0
- annoscript/_backend_profiles.py +17 -0
- annoscript/_client_common.py +287 -0
- annoscript/_data.py +37 -0
- annoscript/_dispatch_pool.py +47 -0
- annoscript/_env.py +19 -0
- annoscript/_examples.py +34 -0
- annoscript/_expressions.py +93 -0
- annoscript/_plan_viewer.html +953 -0
- annoscript/_remote_jobs.py +211 -0
- annoscript/_request_adapters.py +71 -0
- annoscript/_speculation.py +321 -0
- annoscript/_test_artifacts.py +33 -0
- annoscript/_test_evaluation_http.py +117 -0
- annoscript/_test_ir_runtime.py +138 -0
- annoscript/_test_job_process.py +166 -0
- annoscript/_test_plan_runtime.py +331 -0
- annoscript/_test_remote_client.py +46 -0
- annoscript/_test_remote_server.py +75 -0
- annoscript/_test_synthetic.py +67 -0
- annoscript/anno.py +270 -0
- annoscript/backends/__init__.py +13 -0
- annoscript/backends/_batch_wire.py +26 -0
- annoscript/backends/_bounded_grammar.py +97 -0
- annoscript/backends/_example_layout.py +23 -0
- annoscript/backends/_generation_policy.py +116 -0
- annoscript/backends/_grammar.py +83 -0
- annoscript/backends/_preview.py +13 -0
- annoscript/backends/_prompt.py +115 -0
- annoscript/backends/_shared_rules.py +38 -0
- annoscript/backends/adapters/__init__.py +17 -0
- annoscript/backends/adapters/chat.py +39 -0
- annoscript/backends/adapters/decision.py +119 -0
- annoscript/backends/base.py +427 -0
- annoscript/backends/config.py +67 -0
- annoscript/backends/decision.py +150 -0
- annoscript/backends/llm.py +338 -0
- annoscript/client.py +247 -0
- annoscript/client_tests.py +17 -0
- annoscript/compiler.py +708 -0
- annoscript/cross_version_tests.py +68 -0
- annoscript/fixtures/request-adapters-v1.json +4741 -0
- annoscript/formatter.py +171 -0
- annoscript/grammar_tests.py +176 -0
- annoscript/inspect.py +77 -0
- annoscript/ir.py +458 -0
- annoscript/job_cross_version_tests.py +118 -0
- annoscript/job_protocol.py +180 -0
- annoscript/jobs.py +255 -0
- annoscript/metrics.py +153 -0
- annoscript/p0_grammar_tests.py +147 -0
- annoscript/passes/__init__.py +40 -0
- annoscript/passes/_analysis.py +178 -0
- annoscript/passes/control_flow.py +68 -0
- annoscript/passes/dead_assignments.py +44 -0
- annoscript/passes/folding.py +104 -0
- annoscript/passes/propagation.py +53 -0
- annoscript/passes/temporaries.py +45 -0
- annoscript/plan.py +542 -0
- annoscript/plan_formatter.py +452 -0
- annoscript/plan_lowering.py +571 -0
- annoscript/preview.py +115 -0
- annoscript/remote.py +304 -0
- annoscript/runtime.py +861 -0
- annoscript/server/__init__.py +1 -0
- annoscript/server/__main__.py +49 -0
- annoscript/server/_plan_cache.py +79 -0
- annoscript/server/api.py +146 -0
- annoscript/server/metrics.py +140 -0
- annoscript/server/migrate.py +61 -0
- annoscript/server/releases.py +143 -0
- annoscript/server/store.py +429 -0
- annoscript/server/worker.py +290 -0
- annoscript/substring_tests.py +79 -0
- annoscript/test_advisory_lengths.py +115 -0
- annoscript/test_backends.py +181 -0
- annoscript/test_client.py +349 -0
- annoscript/test_compact_batch.py +95 -0
- annoscript/test_compiler.py +444 -0
- annoscript/test_decision_adapters.py +410 -0
- annoscript/test_decision_wire.py +190 -0
- annoscript/test_diagnostics.py +97 -0
- annoscript/test_engineering_p0.py +191 -0
- annoscript/test_environment.py +65 -0
- annoscript/test_example_backends.py +251 -0
- annoscript/test_examples.py +149 -0
- annoscript/test_field_names.py +221 -0
- annoscript/test_formatter.py +153 -0
- annoscript/test_fusion_scheduling.py +109 -0
- annoscript/test_generation_policy.py +180 -0
- annoscript/test_ir.py +148 -0
- annoscript/test_jobs.py +371 -0
- annoscript/test_jobs_server.py +511 -0
- annoscript/test_llm_batch.py +428 -0
- annoscript/test_metrics.py +59 -0
- annoscript/test_metrics_server.py +261 -0
- annoscript/test_minimal_fusion.py +59 -0
- annoscript/test_nullable_generate.py +169 -0
- annoscript/test_passes.py +408 -0
- annoscript/test_plan.py +522 -0
- annoscript/test_plan_constraints.py +305 -0
- annoscript/test_plan_formatter.py +272 -0
- annoscript/test_prompt_layout.py +140 -0
- annoscript/test_qwen_backend.py +181 -0
- annoscript/test_qwen_pilot_regression.py +180 -0
- annoscript/test_release_client.py +20 -0
- annoscript/test_remote.py +308 -0
- annoscript/test_request_adapters.py +295 -0
- annoscript/test_requirements.py +228 -0
- annoscript/test_rule_dedup.py +67 -0
- annoscript/test_runtime.py +314 -0
- annoscript/test_runtime_client.py +133 -0
- annoscript/test_source_fields.py +111 -0
- annoscript/test_speculation.py +458 -0
- annoscript/test_substring_backend.py +81 -0
- annoscript/test_synthetic_example.py +61 -0
- annoscript-0.1.0.dist-info/METADATA +145 -0
- annoscript-0.1.0.dist-info/RECORD +128 -0
- annoscript-0.1.0.dist-info/WHEEL +4 -0
- annoscript-0.1.0.dist-info/licenses/LICENSE +21 -0
- annoscript_examples/__init__.py +1 -0
- annoscript_examples/fixtures.py +3 -0
- annoscript_examples/request.execution_plan.json +3017 -0
- annoscript_examples/request.json +858 -0
- annoscript_examples/request.py +37 -0
- annoscript_examples/request_preview.decision.json +23 -0
- annoscript_examples/request_preview.generation.json +20 -0
- annoscript_examples/run.py +24 -0
|
@@ -0,0 +1,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()
|