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,428 @@
|
|
|
1
|
+
"""Offline wire, scheduling, bounded repair and car acceptance for LLM batches."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import unittest
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from annoscript._test_evaluation_http import llm_calls, llm_content, llm_wire_id
|
|
10
|
+
from annoscript.backends import InferenceContext, InferenceError, BackendFatalError
|
|
11
|
+
from annoscript._test_evaluation_http import FixtureLLMBackend as LLMAPIBackend
|
|
12
|
+
from annoscript.backends.base import normalize_call, encode_payload
|
|
13
|
+
from annoscript.backends._bounded_grammar import grammar
|
|
14
|
+
def batch_grammar(calls): return grammar(calls, batch=True)
|
|
15
|
+
from annoscript.compiler import compile_program
|
|
16
|
+
from annoscript.anno import GenerateResponse, GenerateListResponse, ExtractResponse
|
|
17
|
+
from annoscript.plan_lowering import lower_to_plan
|
|
18
|
+
from annoscript.runtime import AnnoRuntime, RunInput
|
|
19
|
+
from annoscript.test_runtime import FakeBackend
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def mixed_generation(anno):
|
|
23
|
+
title = anno.generate("标题", max_length=8, max_num_tokens=16).text
|
|
24
|
+
labels = anno.generate_list("维度", max_items=2, max_num_tokens=32, max_length_per_item=58).items
|
|
25
|
+
places = anno.extract("地点", min_items=1, max_items=8, max_length_per_item=58).items
|
|
26
|
+
return [title, labels, places]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def independent_generation(anno):
|
|
30
|
+
a = anno.generate("first", max_length=512).text
|
|
31
|
+
b = anno.generate("second", max_length=512).text
|
|
32
|
+
c = anno.generate("third", max_length=512).text
|
|
33
|
+
return [a, b, c]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def dependent_generation(anno):
|
|
37
|
+
a = anno.generate("first", max_length=512).text
|
|
38
|
+
b = anno.generate("second", max_length=512).text
|
|
39
|
+
return [a, b, anno.generate("dependent:" + a, max_length=512).text]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def conditional_generation(anno):
|
|
43
|
+
gate = anno.generate("no", max_length=512).text
|
|
44
|
+
if gate == "yes":
|
|
45
|
+
return anno.generate("inactive", max_length=512).text
|
|
46
|
+
return gate
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def generation_loop(anno):
|
|
50
|
+
result = []
|
|
51
|
+
for item in anno.state:
|
|
52
|
+
result = result + [anno.generate(item, max_length=512).text]
|
|
53
|
+
return result
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def reused_generation(anno):
|
|
57
|
+
a = anno.generate("same", max_length=512).text
|
|
58
|
+
b = anno.generate("same", max_length=512).text
|
|
59
|
+
return [a, b, anno.generate("different", max_length=512).text]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def answer(context, call):
|
|
63
|
+
if call.operation == "generate":
|
|
64
|
+
return GenerateResponse(call.arguments["instructions"])
|
|
65
|
+
if call.operation == "generate_list":
|
|
66
|
+
return GenerateListResponse(["条目"])
|
|
67
|
+
return ExtractResponse(["广州"])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def completion(content, *, finish="stop", refusal=None):
|
|
71
|
+
return httpx.Response(200, json={"choices": [{"finish_reason": finish,
|
|
72
|
+
"message": {"content": content, "refusal": refusal}}],
|
|
73
|
+
"usage": {"prompt_tokens": 13, "completion_tokens": 5}})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def wire(handler=None, *, profile="legacy"):
|
|
77
|
+
requests, sizes = [], []
|
|
78
|
+
async def handle(request):
|
|
79
|
+
body = json.loads(request.content)
|
|
80
|
+
requests.append(body)
|
|
81
|
+
sizes.append(len(request.content))
|
|
82
|
+
if handler is not None:
|
|
83
|
+
response = handler(body)
|
|
84
|
+
if hasattr(response, "__await__"):
|
|
85
|
+
response = await response
|
|
86
|
+
if response is not None:
|
|
87
|
+
return response
|
|
88
|
+
return completion(llm_content(body, answer))
|
|
89
|
+
backend = LLMAPIBackend("https://offline.invalid/v1", "fixture", "fixture", profile=profile,
|
|
90
|
+
transport=httpx.MockTransport(handle))
|
|
91
|
+
backend.test_request_sizes = sizes
|
|
92
|
+
return backend, requests
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def execute(function=independent_generation, state="state", *, handler=None, profile="legacy",
|
|
96
|
+
mode="eager", inputs=None, **options):
|
|
97
|
+
backend, requests = wire(handler, profile=profile)
|
|
98
|
+
async with AnnoRuntime(FakeBackend(), backend, trace=True, **options) as runtime:
|
|
99
|
+
plan = lower_to_plan(compile_program(function))
|
|
100
|
+
stream = await runtime.submit(plan, inputs or [RunInput(0, state)], mode=mode)
|
|
101
|
+
results = [r async for r in stream]
|
|
102
|
+
return results, requests, runtime.trace
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BatchBackendTests(unittest.IsolatedAsyncioTestCase):
|
|
106
|
+
def calls(self):
|
|
107
|
+
context = InferenceContext({"text": "背景", "places": "广州与北京"}, "共同规则")
|
|
108
|
+
calls = [normalize_call("a", "generate", ("标题",), {"max_num_tokens": 16, 'max_length': 512}, context),
|
|
109
|
+
normalize_call("b", "generate_list", ("维度",), {"max_num_tokens": 32, 'max_items': 8, 'max_length_per_item': 58}, context),
|
|
110
|
+
normalize_call("c", "extract", ("地点",), {"source_field": "places", 'max_items': 8, 'max_length_per_item': 58}, context),
|
|
111
|
+
normalize_call("d", "extract", ("城市",), {"source_field": "places", 'max_items': 8, 'max_length_per_item': 58}, context)]
|
|
112
|
+
return context, calls
|
|
113
|
+
|
|
114
|
+
async def test_mixed_profiles_source_dedup_reordering_and_exact_bytes(self):
|
|
115
|
+
context, calls = self.calls()
|
|
116
|
+
for profile in ("legacy", "qwen_vllm"):
|
|
117
|
+
def reverse(body):
|
|
118
|
+
value = json.loads(llm_content(body, answer))
|
|
119
|
+
value["answers"] = dict(reversed(list(value["answers"].items())))
|
|
120
|
+
return completion(json.dumps(value, ensure_ascii=False))
|
|
121
|
+
backend, requests = wire(reverse, profile=profile)
|
|
122
|
+
try:
|
|
123
|
+
plan = backend.plan_batch(context, calls, max_calls=8, max_input_bytes=262144,
|
|
124
|
+
max_output_tokens=16384, default_tokens=1024)
|
|
125
|
+
self.assertEqual(plan.max_tokens, 64 + 16 + 32 + 1024 * 2 + 32 * 4)
|
|
126
|
+
self.assertEqual(requests, [])
|
|
127
|
+
result = await backend.infer_batch(context, plan.calls, max_tokens=plan.max_tokens)
|
|
128
|
+
self.assertEqual(result.responses["a"].text, "标题")
|
|
129
|
+
self.assertEqual(result.responses["c"].items, ["广州"])
|
|
130
|
+
self.assertEqual(result.errors, {})
|
|
131
|
+
body = requests[0]
|
|
132
|
+
self.assertEqual(len(encode_payload(body)), plan.input_bytes)
|
|
133
|
+
self.assertEqual(backend.test_request_sizes, [plan.input_bytes])
|
|
134
|
+
payload = json.loads(body["messages"][-1]["content"])
|
|
135
|
+
self.assertEqual(payload, {"text": "背景", "places": "广州与北京"})
|
|
136
|
+
self.assertNotIn("sources", payload)
|
|
137
|
+
self.assertIn('输入的顶层字段 "places"', body["messages"][0]["content"])
|
|
138
|
+
if profile == "qwen_vllm":
|
|
139
|
+
self.assertEqual([m["role"] for m in body["messages"]], ["system", "user"])
|
|
140
|
+
self.assertTrue(body["messages"][0]["content"].startswith("共同规则\n"))
|
|
141
|
+
self.assertEqual(body["structured_outputs"], {"grammar": batch_grammar(calls)})
|
|
142
|
+
self.assertEqual(body["chat_template_kwargs"], {"enable_thinking": False})
|
|
143
|
+
else:
|
|
144
|
+
self.assertTrue(body["messages"][0]["content"].startswith("共同规则\n"))
|
|
145
|
+
self.assertEqual(body["response_format"], {"type": "json_object"})
|
|
146
|
+
self.assertEqual(body["thinking"], {"type": "disabled"})
|
|
147
|
+
finally:
|
|
148
|
+
await backend.aclose()
|
|
149
|
+
|
|
150
|
+
async def test_planning_boundaries_do_not_truncate_or_expand_individual_calls(self):
|
|
151
|
+
context, calls = self.calls()
|
|
152
|
+
backend, requests = wire()
|
|
153
|
+
limits = dict(max_calls=8, max_input_bytes=262144, max_output_tokens=16384, default_tokens=1024)
|
|
154
|
+
two = backend.plan_batch(context, calls[:2], **limits)
|
|
155
|
+
for change in ({"max_calls": 2}, {"max_output_tokens": two.max_tokens}, {"max_input_bytes": two.input_bytes}):
|
|
156
|
+
plan = backend.plan_batch(context, calls, **(limits | change))
|
|
157
|
+
self.assertEqual(plan.calls, tuple(calls[:2]))
|
|
158
|
+
for change in ({"max_calls": 1}, {"max_output_tokens": two.max_tokens - 1}, {"max_input_bytes": two.input_bytes - 1}):
|
|
159
|
+
self.assertIsNone(backend.plan_batch(context, calls[:2], **(limits | change)))
|
|
160
|
+
self.assertEqual(requests, [])
|
|
161
|
+
await backend.aclose()
|
|
162
|
+
|
|
163
|
+
async def test_batch_thinking_override_can_be_omitted(self):
|
|
164
|
+
context, calls = self.calls()
|
|
165
|
+
for profile in ("legacy", "qwen_vllm"):
|
|
166
|
+
backend, requests = wire(profile=profile)
|
|
167
|
+
backend.disable_thinking = False
|
|
168
|
+
try:
|
|
169
|
+
await backend.infer_batch(context, calls, max_tokens=4096)
|
|
170
|
+
self.assertNotIn("thinking", requests[0])
|
|
171
|
+
self.assertNotIn("chat_template_kwargs", requests[0])
|
|
172
|
+
finally:
|
|
173
|
+
await backend.aclose()
|
|
174
|
+
|
|
175
|
+
async def test_per_item_errors_preserve_good_results(self):
|
|
176
|
+
context, calls = self.calls()
|
|
177
|
+
def invalid(body):
|
|
178
|
+
value = json.loads(llm_content(body, answer))
|
|
179
|
+
del value["answers"]["q2"]
|
|
180
|
+
value["answers"]["q3"] = ["深圳"]
|
|
181
|
+
value["answers"]["q4"] = "wrong value type"
|
|
182
|
+
return completion(json.dumps(value))
|
|
183
|
+
backend, _ = wire(invalid)
|
|
184
|
+
try:
|
|
185
|
+
result = await backend.infer_batch(context, calls, max_tokens=2048)
|
|
186
|
+
self.assertEqual(set(result.responses), {"a"})
|
|
187
|
+
self.assertEqual(set(result.errors), {"b", "c", "d"})
|
|
188
|
+
self.assertTrue(all(e.call_id == key for key, e in result.errors.items()))
|
|
189
|
+
finally:
|
|
190
|
+
await backend.aclose()
|
|
191
|
+
|
|
192
|
+
async def test_unreliable_mapping_truncation_refusal_and_http_classification(self):
|
|
193
|
+
context, calls = self.calls()
|
|
194
|
+
cases = [
|
|
195
|
+
(completion('{"answers":{'), "invalid_batch_output"),
|
|
196
|
+
(completion('{"answers":{"q1":[],"q1":[]}}'), "invalid_batch_output"),
|
|
197
|
+
(completion('{"answers":{"unknown":{}}}'), "invalid_batch_output"),
|
|
198
|
+
(completion('{"answers":{}}', finish="length"), "incomplete_batch_generation"),
|
|
199
|
+
(completion(None, refusal="no"), "model_refusal"),
|
|
200
|
+
(completion(None, finish="content_filter"), "model_refusal"),
|
|
201
|
+
(httpx.Response(200, json={"choices": [{"finish_reason": "content_filter"}]}), "model_refusal"),
|
|
202
|
+
(httpx.Response(429, json={}), "model_http_error"),
|
|
203
|
+
(httpx.Response(200, text="not JSON"), "invalid_response"),
|
|
204
|
+
]
|
|
205
|
+
for response, code in cases:
|
|
206
|
+
backend, requests = wire(lambda _: response)
|
|
207
|
+
try:
|
|
208
|
+
with self.subTest(code=code), self.assertRaises(InferenceError) as caught:
|
|
209
|
+
await backend.infer_batch(context, calls, max_tokens=1024)
|
|
210
|
+
self.assertEqual(caught.exception.code, code)
|
|
211
|
+
self.assertEqual(len(requests), 1)
|
|
212
|
+
finally:
|
|
213
|
+
await backend.aclose()
|
|
214
|
+
|
|
215
|
+
async def test_unicode_and_all_per_item_contracts(self):
|
|
216
|
+
source = ' 中文😀 "引号" \\路径\n换行\t '
|
|
217
|
+
context = InferenceContext(source)
|
|
218
|
+
calls = [normalize_call("a", "generate", ("text",), {"min_length": 1, "max_length": len(source)}, context),
|
|
219
|
+
normalize_call("b", "generate_list", ("list",), {"min_items": 1, "max_items": 1, "max_length_per_item": len(source)}, context),
|
|
220
|
+
normalize_call("c", "extract", ("source",), {"min_items": 1, "max_length_per_item": len(source), 'max_items': 8}, context)]
|
|
221
|
+
valid = {"q1": source, "q2": [source], "q3": [source]}
|
|
222
|
+
for ascii_only in (False, True):
|
|
223
|
+
backend, _ = wire(lambda _: completion(json.dumps({"answers": valid}, ensure_ascii=ascii_only)))
|
|
224
|
+
try:
|
|
225
|
+
result = await backend.infer_batch(context, calls, max_tokens=1024)
|
|
226
|
+
self.assertEqual(result.responses["a"].text, source)
|
|
227
|
+
self.assertEqual(result.responses["c"].items, [source])
|
|
228
|
+
finally:
|
|
229
|
+
await backend.aclose()
|
|
230
|
+
for key, identity, value in (("q1", "a", None), ("q1", "a", ""),
|
|
231
|
+
("q2", "b", []), ("q2", "b", [source, source]),
|
|
232
|
+
("q3", "c", [""])):
|
|
233
|
+
invalid = json.loads(json.dumps(valid))
|
|
234
|
+
invalid[key] = value
|
|
235
|
+
backend, _ = wire(lambda _: completion(json.dumps({"answers": invalid})))
|
|
236
|
+
try:
|
|
237
|
+
result = await backend.infer_batch(context, calls, max_tokens=1024)
|
|
238
|
+
self.assertEqual(set(result.errors), {identity})
|
|
239
|
+
finally:
|
|
240
|
+
await backend.aclose()
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class BatchRuntimeTests(unittest.IsolatedAsyncioTestCase):
|
|
244
|
+
async def test_default_mixed_batch_and_disable_restores_original_wire(self):
|
|
245
|
+
for profile in ("legacy", "qwen_vllm"):
|
|
246
|
+
results, requests, trace = await execute(mixed_generation, state="广州与北京", profile=profile)
|
|
247
|
+
self.assertEqual(results[0].value, ["标题", ["条目"], ["广州"]])
|
|
248
|
+
self.assertEqual(len(requests), 1)
|
|
249
|
+
self.assertEqual(requests[0]["max_tokens"], 64 + 16 + 32 + 1024 + 32 * 3)
|
|
250
|
+
self.assertEqual(sum(e.get("usage", {}).get("prompt_tokens", 0) for e in trace), 13)
|
|
251
|
+
results, requests, _ = await execute(mixed_generation, state="广州与北京", profile=profile, max_llm_batch_calls=1)
|
|
252
|
+
self.assertEqual(len(requests), 3)
|
|
253
|
+
self.assertEqual([r.get("max_tokens") for r in requests], [16, 32, 1024])
|
|
254
|
+
self.assertTrue(all(not llm_calls(r)[0] for r in requests))
|
|
255
|
+
|
|
256
|
+
async def test_count_byte_budget_and_oversized_single_fallback(self):
|
|
257
|
+
_, reference, _ = await execute(max_llm_batch_calls=2)
|
|
258
|
+
two_call_bytes = len(encode_payload(reference[0]))
|
|
259
|
+
for options in ({"max_llm_batch_calls": 2}, {"max_llm_batch_output_tokens": 64 + 2 * (1024 + 32)},
|
|
260
|
+
{"max_llm_batch_input_bytes": two_call_bytes}):
|
|
261
|
+
results, requests, _ = await execute(**options)
|
|
262
|
+
self.assertEqual(results[0].value, ["first", "second", "third"])
|
|
263
|
+
self.assertEqual([len(llm_calls(r)[1]) for r in requests], [2, 1])
|
|
264
|
+
for options in ({"max_llm_batch_input_bytes": 1}, {"max_llm_batch_output_tokens": 1}):
|
|
265
|
+
results, requests, _ = await execute(**options)
|
|
266
|
+
self.assertEqual(results[0].status, "success")
|
|
267
|
+
self.assertEqual(len(requests), 3)
|
|
268
|
+
self.assertTrue(all(r["max_tokens"] == 1024 for r in requests))
|
|
269
|
+
|
|
270
|
+
async def test_dependencies_strict_activation_reuse_and_cross_input_isolation(self):
|
|
271
|
+
_, requests, _ = await execute(dependent_generation)
|
|
272
|
+
self.assertEqual([len(llm_calls(r)[1]) for r in requests], [2, 1])
|
|
273
|
+
self.assertEqual(llm_calls(requests[-1])[1][0][1].arguments["instructions"], "dependent:first")
|
|
274
|
+
_, requests, _ = await execute(mode="strict")
|
|
275
|
+
self.assertEqual([llm_calls(r)[1][0][1].arguments["instructions"] for r in requests], ["first", "second", "third"])
|
|
276
|
+
_, requests, _ = await execute(conditional_generation)
|
|
277
|
+
self.assertEqual(len(requests), 1)
|
|
278
|
+
results, requests, _ = await execute(reused_generation)
|
|
279
|
+
self.assertEqual(results[0].value, ["same", "same", "different"])
|
|
280
|
+
self.assertEqual(len(llm_calls(requests[0])[1]), 2)
|
|
281
|
+
results, requests, _ = await execute(inputs=[RunInput(0, "same"), RunInput(1, "same")])
|
|
282
|
+
self.assertEqual(len(results), 2)
|
|
283
|
+
self.assertEqual(len(requests), 2)
|
|
284
|
+
self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 3])
|
|
285
|
+
|
|
286
|
+
async def test_loops_local_quantum_and_physical_concurrency_bound(self):
|
|
287
|
+
active = peak = 0
|
|
288
|
+
async def handle(body):
|
|
289
|
+
nonlocal active, peak
|
|
290
|
+
active += 1
|
|
291
|
+
peak = max(peak, active)
|
|
292
|
+
await asyncio.sleep(0)
|
|
293
|
+
active -= 1
|
|
294
|
+
results, requests, _ = await execute(generation_loop, [str(i) for i in range(19)], handler=handle,
|
|
295
|
+
max_llm_batch_calls=3, loop_window=5, local_task_budget=20, max_llm_inflight=2)
|
|
296
|
+
self.assertEqual(results[0].value, [str(i) for i in range(19)])
|
|
297
|
+
self.assertLessEqual(peak, 2)
|
|
298
|
+
sizes = [len(llm_calls(r)[1]) for r in requests]
|
|
299
|
+
self.assertEqual(sum(sizes), 19)
|
|
300
|
+
self.assertLessEqual(max(sizes), 3)
|
|
301
|
+
self.assertGreater(max(sizes), 1)
|
|
302
|
+
|
|
303
|
+
async def test_partial_repair_keeps_good_answers_and_records_usage_once(self):
|
|
304
|
+
def fail_one(body):
|
|
305
|
+
if llm_calls(body)[0]:
|
|
306
|
+
value = json.loads(llm_content(body, answer))
|
|
307
|
+
del value["answers"][next(iter(value["answers"]))]
|
|
308
|
+
return completion(json.dumps(value))
|
|
309
|
+
results, requests, trace = await execute(handler=fail_one)
|
|
310
|
+
self.assertEqual(results[0].value, ["first", "second", "third"])
|
|
311
|
+
self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 1])
|
|
312
|
+
self.assertEqual(llm_calls(requests[1])[1][0][1].arguments["instructions"], "first")
|
|
313
|
+
self.assertEqual(requests[1]["max_tokens"], 1024)
|
|
314
|
+
self.assertIn("未通过格式或约束检查", requests[1]["messages"][0]["content"])
|
|
315
|
+
submitted = [e for e in trace if e["event"] == "http_submitted"]
|
|
316
|
+
self.assertEqual(submitted[1]["repair_parent"], submitted[0]["batch"])
|
|
317
|
+
self.assertEqual(submitted[1]["repair_attempt"], 1)
|
|
318
|
+
self.assertEqual(sum(e.get("usage", {}).get("prompt_tokens", 0) for e in trace), 26)
|
|
319
|
+
|
|
320
|
+
async def test_unreliable_batch_repairs_every_item_once_and_failure_is_terminal(self):
|
|
321
|
+
for bad in (completion("{"), completion('{"answers":{}}', finish="length")):
|
|
322
|
+
def handle(body):
|
|
323
|
+
return bad if llm_calls(body)[0] else None
|
|
324
|
+
results, requests, _ = await execute(handler=handle)
|
|
325
|
+
self.assertEqual(results[0].status, "success")
|
|
326
|
+
self.assertEqual([len(llm_calls(r)[1]) for r in requests], [3, 1, 1, 1])
|
|
327
|
+
def fail_again(body):
|
|
328
|
+
return completion("{") if llm_calls(body)[0] else completion("", finish="length")
|
|
329
|
+
results, requests, _ = await execute(handler=fail_again, max_llm_inflight=1)
|
|
330
|
+
self.assertEqual(results[0].status, "error")
|
|
331
|
+
self.assertEqual(len(requests), 2)
|
|
332
|
+
results, requests, _ = await execute(handler=lambda _: completion("{"), llm_batch_repair_attempts=0)
|
|
333
|
+
self.assertEqual(results[0].status, "error")
|
|
334
|
+
self.assertEqual(len(requests), 1)
|
|
335
|
+
|
|
336
|
+
async def test_no_content_repair_on_network_errors_or_refusal(self):
|
|
337
|
+
for response in (httpx.Response(429, json={}), completion(None, refusal="no"),
|
|
338
|
+
httpx.Response(200, json={"choices": [{"finish_reason": "content_filter"}]})):
|
|
339
|
+
results, requests, _ = await execute(handler=lambda _: response)
|
|
340
|
+
self.assertEqual(results[0].status, "error")
|
|
341
|
+
self.assertEqual(len(requests), 1)
|
|
342
|
+
for error in (httpx.ReadTimeout("offline"), httpx.ReadError("offline")):
|
|
343
|
+
def fail(body):
|
|
344
|
+
raise error
|
|
345
|
+
results, requests, _ = await execute(handler=fail)
|
|
346
|
+
self.assertEqual(results[0].status, "error")
|
|
347
|
+
self.assertEqual(len(requests), 1)
|
|
348
|
+
def disconnected(body):
|
|
349
|
+
raise httpx.ConnectError("offline")
|
|
350
|
+
backend, requests = wire(disconnected)
|
|
351
|
+
async with AnnoRuntime(FakeBackend(), backend) as runtime:
|
|
352
|
+
stream = await runtime.submit(lower_to_plan(compile_program(independent_generation)), [RunInput(0, "state")])
|
|
353
|
+
with self.assertRaises(BackendFatalError):
|
|
354
|
+
[r async for r in stream]
|
|
355
|
+
self.assertEqual(len(requests), 1)
|
|
356
|
+
|
|
357
|
+
async def test_repair_preserves_explicit_budget_task_state_and_source(self):
|
|
358
|
+
for operation in ("generate", "generate_list", "extract"):
|
|
359
|
+
def invalid(body):
|
|
360
|
+
batched, calls = llm_calls(body)
|
|
361
|
+
if batched:
|
|
362
|
+
value = json.loads(llm_content(body, answer))
|
|
363
|
+
call = next(c for _, c in calls if c.operation == operation)
|
|
364
|
+
value["answers"][llm_wire_id(body, call.id)] = {"type": "wrong"}
|
|
365
|
+
return completion(json.dumps(value))
|
|
366
|
+
results, requests, trace = await execute(mixed_generation, state="广州与北京", handler=invalid, max_llm_inflight=1)
|
|
367
|
+
self.assertEqual(results[0].status, "success")
|
|
368
|
+
self.assertEqual(len(requests), 2)
|
|
369
|
+
context, repair = llm_calls(requests[1])[1][0]
|
|
370
|
+
original = next(c for _, c in llm_calls(requests[0])[1] if c.operation == operation)
|
|
371
|
+
self.assertEqual(repair.arguments, original.arguments)
|
|
372
|
+
self.assertEqual(context.state, "广州与北京")
|
|
373
|
+
self.assertEqual(requests[1]["max_tokens"], {"generate": 16, "generate_list": 32, "extract": 1024}[operation])
|
|
374
|
+
self.assertNotIn("content", str(trace))
|
|
375
|
+
|
|
376
|
+
async def test_strict_failure_order_and_fairness_across_runs(self):
|
|
377
|
+
results, requests, _ = await execute(mode="strict", handler=lambda _: completion("", finish="length"))
|
|
378
|
+
self.assertEqual(results[0].status, "error")
|
|
379
|
+
self.assertEqual(len(requests), 1)
|
|
380
|
+
_, requests, _ = await execute(inputs=[RunInput(0, "A"), RunInput(1, "B")],
|
|
381
|
+
max_llm_batch_calls=2, max_llm_inflight=1)
|
|
382
|
+
self.assertEqual([llm_calls(r)[1][0][0].state for r in requests], ["A", "B", "A", "B"])
|
|
383
|
+
|
|
384
|
+
async def test_repairs_precede_ready_calls_and_other_runs(self):
|
|
385
|
+
def invalid(body):
|
|
386
|
+
batched, calls = llm_calls(body)
|
|
387
|
+
if batched and calls[0][0].state == "A":
|
|
388
|
+
value = json.loads(llm_content(body, answer))
|
|
389
|
+
del value["answers"][llm_wire_id(body, calls[1][1].id)]
|
|
390
|
+
return completion(json.dumps(value))
|
|
391
|
+
results, requests, _ = await execute(dependent_generation, state="A", handler=invalid, max_llm_inflight=1)
|
|
392
|
+
self.assertEqual(results[0].status, "success")
|
|
393
|
+
self.assertEqual([llm_calls(r)[1][-1][1].arguments["instructions"] for r in requests],
|
|
394
|
+
["second", "second", "dependent:first"])
|
|
395
|
+
results, requests, _ = await execute(inputs=[RunInput(0, "A"), RunInput(1, "B")],
|
|
396
|
+
handler=invalid, max_llm_inflight=1)
|
|
397
|
+
self.assertTrue(all(r.status == "success" for r in results))
|
|
398
|
+
self.assertEqual([llm_calls(r)[1][0][0].state for r in requests], ["A", "A", "B"])
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
async def test_cancellation_during_repair_stops_further_dispatch(self):
|
|
402
|
+
started, cancelled = asyncio.Event(), asyncio.Event()
|
|
403
|
+
async def handle(body):
|
|
404
|
+
if llm_calls(body)[0]:
|
|
405
|
+
return completion("{")
|
|
406
|
+
started.set()
|
|
407
|
+
try:
|
|
408
|
+
await asyncio.Event().wait()
|
|
409
|
+
finally:
|
|
410
|
+
cancelled.set()
|
|
411
|
+
backend, requests = wire(handle)
|
|
412
|
+
async with AnnoRuntime(FakeBackend(), backend, max_llm_inflight=1) as runtime:
|
|
413
|
+
stream = await runtime.submit(lower_to_plan(compile_program(independent_generation)), [RunInput(0, "state")])
|
|
414
|
+
await asyncio.wait_for(started.wait(), 2)
|
|
415
|
+
await stream.aclose()
|
|
416
|
+
await asyncio.wait_for(cancelled.wait(), 2)
|
|
417
|
+
self.assertEqual(len(requests), 2)
|
|
418
|
+
|
|
419
|
+
async def test_configuration_validation(self):
|
|
420
|
+
for kwargs in ({"max_llm_batch_calls": 0}, {"max_llm_batch_input_bytes": True},
|
|
421
|
+
{"max_llm_batch_output_tokens": -1}, {"llm_batch_default_tokens": 0},
|
|
422
|
+
{"llm_batch_repair_attempts": 2}, {"llm_batch_repair_attempts": False}):
|
|
423
|
+
with self.assertRaises(ValueError):
|
|
424
|
+
AnnoRuntime(FakeBackend(), FakeBackend(), **kwargs)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
if __name__ == "__main__":
|
|
428
|
+
unittest.main()
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Client-only metric wire/usage contracts; no Runtime or server imports."""
|
|
2
|
+
|
|
3
|
+
from copy import deepcopy
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from annoscript.jobs import AnnoJobClient
|
|
9
|
+
from annoscript.metrics import aggregate, normalize_usage, quantile, validate_metrics
|
|
10
|
+
from annoscript.remote import RemoteProtocolError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def fixture():
|
|
14
|
+
job = {"job_id": "job", "ir_sha256": "a" * 64, "release_id": "b" * 64, "mode": "eager", "input_indices": [3],
|
|
15
|
+
"state": "completed", "counts": {"total": 1, "completed": 1, "success": 1, "error": 0},
|
|
16
|
+
"pause_generation": 0, "pause_reason": None, "created_at": 0, "updated_at": 1}
|
|
17
|
+
attempt = {"metered": 1, "status": "finished", "duration_seconds": 1}
|
|
18
|
+
request = {"backend": "generation", "questions": 1, "http_status": 200, "seconds": .5,
|
|
19
|
+
"usage": normalize_usage({"prompt_tokens": 10, "completion_tokens": 2})}
|
|
20
|
+
summary = aggregate([attempt], [request], history=True, waits=[.1])
|
|
21
|
+
return {"protocol_version": 1, "metrics_version": 1, "job": job, "provisional": False,
|
|
22
|
+
"summary": summary, "inputs": [{"index": 3, "metrics": deepcopy(summary)}]}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MetricContracts(unittest.TestCase):
|
|
26
|
+
def test_usage_aliases_details_missing_invalid_conflicts(self):
|
|
27
|
+
for usage, value, reason in (({}, None, "missing"), ({"input_tokens": True}, None, "invalid"),
|
|
28
|
+
({"input_tokens": -1}, None, "invalid"), ({"input_tokens": 1.5}, None, "invalid"),
|
|
29
|
+
({"input_tokens": "1"}, None, "invalid"), ({"input_tokens": 1, "prompt_tokens": 2}, None, "conflicting"),
|
|
30
|
+
({"input_tokens": 2, "prompt_tokens": 2}, 2, None), ({"prompt_tokens": 0}, 0, None)):
|
|
31
|
+
item = normalize_usage(usage)["input_tokens"]
|
|
32
|
+
self.assertEqual((item["value"], item["reason"]), (value, reason))
|
|
33
|
+
result = normalize_usage({"completion_tokens": 5, "total_tokens": 999, "completion_tokens_details": {"reasoning_tokens": 4}})
|
|
34
|
+
self.assertEqual(result["output_tokens"]["value"], 5)
|
|
35
|
+
|
|
36
|
+
def test_client_binding_and_invalid_responses(self):
|
|
37
|
+
payload = fixture()
|
|
38
|
+
with AnnoJobClient("http://offline", transport=httpx.MockTransport(lambda _: httpx.Response(200, json=payload))) as client:
|
|
39
|
+
self.assertEqual(client.metrics(payload["job"]), payload)
|
|
40
|
+
changed = {**payload["job"], "release_id": "c" * 64}
|
|
41
|
+
with self.assertRaises(RemoteProtocolError):
|
|
42
|
+
client.metrics(changed)
|
|
43
|
+
mutations = [lambda p: p.update(metrics_version=True), lambda p: p.update(provisional=True),
|
|
44
|
+
lambda p: p["job"].update(state=[]), lambda p: p.update(inputs=[None]),
|
|
45
|
+
lambda p: p["inputs"][0].update(index=4), lambda p: p["summary"]["backends"]["generation"].update(requests=2),
|
|
46
|
+
lambda p: p["summary"]["backends"]["generation"]["input_tokens"].update(total=0),
|
|
47
|
+
lambda p: p["summary"]["attempts"]["seconds"].update(p95=-1)]
|
|
48
|
+
for mutation in mutations:
|
|
49
|
+
bad = fixture(); mutation(bad)
|
|
50
|
+
with self.assertRaises(RemoteProtocolError):
|
|
51
|
+
validate_metrics(bad, "job")
|
|
52
|
+
|
|
53
|
+
def test_unknown_history_never_becomes_zero(self):
|
|
54
|
+
metric = aggregate([], [], history=False, waits=[None])
|
|
55
|
+
self.assertIsNone(metric["backends"]["generation"]["input_tokens"]["total"])
|
|
56
|
+
self.assertIsNone(metric["backends"]["generation"]["total_requests"])
|
|
57
|
+
self.assertIsNone(metric["attempts"]["seconds"]["total"])
|
|
58
|
+
self.assertEqual(quantile([1, 2, 3, 4], .95), 4)
|
|
59
|
+
self.assertIsNone(quantile([], .5))
|