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,308 @@
1
+ """Offline HTTP protocol and import-boundary tests (Python 3.10+)."""
2
+
3
+ from copy import deepcopy
4
+ import json
5
+ from pathlib import Path
6
+ import subprocess
7
+ import sys
8
+ import unittest
9
+ from unittest.mock import patch
10
+
11
+ import httpx
12
+
13
+ from annoscript.client import precondition, require, system_prompt
14
+ from annoscript.compiler import CompileError, compile_program
15
+ from annoscript.remote import (AnnoRemoteClient, RemoteProtocolError, RemoteSubmissionError,
16
+ RemoteTransportError, ir_sha256)
17
+
18
+
19
+ def check(state):
20
+ require(state != "invalid", message="invalid record")
21
+
22
+
23
+ @system_prompt("共享规则")
24
+ @precondition(check)
25
+ def checked(anno):
26
+ return anno.generate(anno.state, max_length=512).text
27
+
28
+
29
+ def constant(anno):
30
+ unused = 1
31
+ return None
32
+
33
+
34
+ def unsupported(anno):
35
+ return [x for x in anno.state]
36
+
37
+
38
+ def success(index, value=None):
39
+ return {"type": "result", "index": index, "status": "success", "value": value, "error": None}
40
+
41
+
42
+ def accepted(payload):
43
+ return {"type": "accepted", "protocol_version": 1, "ir_sha256": payload["ir_sha256"]}
44
+
45
+
46
+ def encode(frames):
47
+ return b"".join((json.dumps(f, ensure_ascii=False, allow_nan=False) + "\n").encode() for f in frames)
48
+
49
+
50
+ class Chunks(httpx.SyncByteStream):
51
+ def __init__(self, chunks):
52
+ self.chunks = chunks
53
+ self.closed = False
54
+ self.pulled = 0
55
+
56
+ def __iter__(self):
57
+ for chunk in self.chunks:
58
+ self.pulled += 1
59
+ if isinstance(chunk, Exception):
60
+ raise chunk
61
+ yield chunk
62
+
63
+ def close(self):
64
+ self.closed = True
65
+
66
+
67
+ class RemoteTests(unittest.TestCase):
68
+ def client(self, frames=None, **options):
69
+ self.requests, self.responses = [], []
70
+
71
+ def handle(request):
72
+ payload = json.loads(request.content)
73
+ self.requests.append((request, payload))
74
+ values = frames(payload) if frames else [accepted(payload)] + [
75
+ success(item["index"], item["state"]) for item in reversed(payload["inputs"])
76
+ ] + [{"type": "done"}]
77
+ stream = values if isinstance(values, Chunks) else Chunks([encode(values)])
78
+ self.responses.append(stream)
79
+ return httpx.Response(200, headers={"Content-Type": "application/x-ndjson"}, stream=stream)
80
+
81
+ client = AnnoRemoteClient("http://offline.test/prefix/", transport=httpx.MockTransport(handle), **options)
82
+ self.addCleanup(client.close)
83
+ return client
84
+
85
+ def test_ir_only_request_and_indexed_results(self):
86
+ for ordered in (False, True):
87
+ for mode in ("strict", "eager"):
88
+ with self.subTest(ordered=ordered, mode=mode), self.client(mode=mode, auth_token="test-token") as client:
89
+ inputs = ["甲", "invalid", {"nested": [1, None]}, float("nan"), "乙"]
90
+ results = list(client.submit(checked, inputs, ordered=ordered))
91
+ self.assertEqual([r.index for r in results], [0, 1, 2, 3, 4] if ordered else [1, 3, 4, 2, 0])
92
+ by_index = {r.index: r for r in results}
93
+ for i in (1, 3):
94
+ self.assertEqual(by_index[i].error.code, "invalid_input")
95
+ for i in (0, 2, 4):
96
+ self.assertEqual(by_index[i].value, inputs[i])
97
+ request, payload = self.requests[0]
98
+ self.assertEqual(request.method, "POST")
99
+ self.assertEqual(str(request.url), "http://offline.test/prefix/v1/runs:batch")
100
+ self.assertEqual(request.headers["Authorization"], "Bearer test-token")
101
+ self.assertEqual(set(payload), {"protocol_version", "ir", "ir_sha256", "mode", "inputs"})
102
+ self.assertEqual(payload["mode"], mode)
103
+ self.assertEqual(payload["ir"], compile_program(checked).to_dict())
104
+ self.assertEqual(payload["ir_sha256"], ir_sha256(payload["ir"]))
105
+ self.assertEqual([item["index"] for item in payload["inputs"]], [0, 2, 4])
106
+ self.assertNotIn("invalid record", request.content.decode())
107
+ self.assertNotIn("test-token", request.content.decode())
108
+ self.assertTrue(self.responses[0].closed)
109
+
110
+ def test_empty_invalid_only_and_checker_defect_make_no_http_calls(self):
111
+ with self.client() as client:
112
+ self.assertEqual(list(client.submit(checked, [])), [])
113
+ self.assertEqual([r.error.code for r in client.submit(checked, ["invalid", 1, {1: "bad"}])],
114
+ ["invalid_input"] * 3)
115
+ for bad in (lambda state: 42, lambda state: 1 / 0):
116
+ with patch("annoscript._client_common._PRECONDITIONS", {checked: (bad,)}):
117
+ with self.assertRaises((TypeError, ZeroDivisionError)):
118
+ client.submit(checked, ["first", "second"])
119
+ with self.assertRaises(CompileError):
120
+ client.submit(unsupported, ["first"])
121
+ self.assertEqual(self.requests, [])
122
+ self.assertIsNone(client._http)
123
+
124
+ def test_snapshot_and_optimize_switch(self):
125
+ for optimize in (False, True):
126
+ with self.client(optimize=optimize) as client:
127
+ inputs = [{"items": ["original"]}]
128
+ before = deepcopy(inputs)
129
+ stream = client.submit(constant, inputs)
130
+ inputs[0]["items"].append("changed")
131
+ self.assertEqual(list(stream)[0].value, before[0])
132
+ payload = self.requests[0][1]
133
+ self.assertEqual(payload["inputs"][0]["state"], before[0])
134
+ self.assertEqual(payload["ir"], compile_program(constant, optimize=optimize).to_dict())
135
+ self.assertNotEqual(compile_program(constant, optimize=False).to_json(), compile_program(constant).to_json())
136
+
137
+ def test_values_and_per_item_errors(self):
138
+ def frames(payload):
139
+ return [accepted(payload), success(2, [None, {"中文": 1}]),
140
+ {"type": "result", "index": 1, "status": "error", "value": None,
141
+ "error": {"code": "constraint_failed", "message": "failed"}},
142
+ success(0), {"type": "done"}]
143
+ with self.client(frames) as client:
144
+ results = list(client.submit(constant, ["a", "b", "c"], ordered=True))
145
+ self.assertEqual(results[0].status, "success")
146
+ self.assertIsNone(results[0].value)
147
+ self.assertEqual(results[1].error.code, "constraint_failed")
148
+ self.assertEqual(results[2].value, [None, {"中文": 1}])
149
+
150
+ def test_protocol_rejects_mismatches_duplicates_missing_and_malformed_results(self):
151
+ mutations = [
152
+ lambda p: [],
153
+ lambda p: [success(0), {"type": "done"}],
154
+ lambda p: [{**accepted(p), "protocol_version": True}],
155
+ lambda p: [{**accepted(p), "protocol_version": 2}],
156
+ lambda p: [{**accepted(p), "ir_sha256": "wrong"}],
157
+ lambda p: [accepted(p), accepted(p)],
158
+ lambda p: [accepted(p), success(0), success(0), {"type": "done"}],
159
+ lambda p: [accepted(p), success(4)],
160
+ lambda p: [accepted(p), success(True)],
161
+ lambda p: [accepted(p), {"type": "done"}],
162
+ lambda p: [accepted(p), success(0)],
163
+ lambda p: [accepted(p), {"type": "result", "index": 0, "status": "success"}],
164
+ lambda p: [accepted(p), {**success(0), "status": "unknown"}],
165
+ lambda p: [accepted(p), {**success(0), "error": {"code": "x", "message": "bad"}}],
166
+ lambda p: [accepted(p), {**success(0), "status": "error"}],
167
+ lambda p: [accepted(p), success(0), {"type": "done"}, success(0)],
168
+ lambda p: [accepted(p), success(0), {"type": "done"}, None],
169
+ ]
170
+ for frames in mutations:
171
+ for ordered in (False, True):
172
+ with self.subTest(frames=mutations.index(frames), ordered=ordered), self.client(frames) as client:
173
+ stream = client.submit(constant, ["a"], ordered=ordered)
174
+ with self.assertRaises(RemoteProtocolError):
175
+ list(stream)
176
+ self.assertTrue(stream.closed)
177
+ self.assertTrue(self.responses[0].closed)
178
+ self.assertEqual(len(self.requests), 1)
179
+
180
+ def test_json_and_utf8_errors_and_chunk_boundaries(self):
181
+ invalid = [b"{}", b"\n", b"not json\n", b"\xff\n", b"[]\n",
182
+ b'{"type":"done","type":"done"}\n', b'{"x":NaN}\n',
183
+ b'{"x":1e999}\n']
184
+ for data in invalid:
185
+ with self.subTest(data=data), self.client(lambda p: Chunks([encode([accepted(p)]), data])) as client:
186
+ with self.assertRaises(RemoteProtocolError):
187
+ list(client.submit(constant, ["a"]))
188
+ def bytes_one_at_a_time(p):
189
+ data = encode([accepted(p), success(0, "中文"), {"type": "done"}]).replace(b"\n", b"\r\n")
190
+ return Chunks([data[i:i+1] for i in range(len(data))])
191
+ with self.client(bytes_one_at_a_time) as client:
192
+ self.assertEqual(list(client.submit(constant, ["a"]))[0].value, "中文")
193
+
194
+ def test_http_rejection_and_submission_error_preserve_metadata(self):
195
+ for status in (302, 401, 403, 429, 503):
196
+ requests = []
197
+ def handle(request):
198
+ requests.append(request)
199
+ return httpx.Response(status, headers={"Retry-After": "12", "Location": "http://elsewhere.test/"},
200
+ json={"error": {"code": "rejected", "message": "try later"}})
201
+ with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(handle)) as client:
202
+ with self.assertRaises(RemoteSubmissionError) as caught:
203
+ client.submit(constant, ["a"])
204
+ self.assertEqual((caught.exception.code, caught.exception.http_status, caught.exception.retry_after),
205
+ ("rejected", status, "12"))
206
+ self.assertEqual(len(requests), 1)
207
+ with self.client(lambda p: [accepted(p), success(0), {"type": "error", "error": {
208
+ "code": "backend_unavailable", "message": "unavailable"}}]) as client:
209
+ stream = client.submit(constant, ["a", "b"])
210
+ self.assertEqual(next(stream).index, 0)
211
+ with self.assertRaises(RemoteSubmissionError) as caught:
212
+ next(stream)
213
+ self.assertEqual(caught.exception.code, "backend_unavailable")
214
+ self.assertTrue(stream.closed)
215
+
216
+ def test_transport_failures_are_not_retried(self):
217
+ for error_type in (httpx.ConnectError, httpx.ConnectTimeout, httpx.ReadTimeout, httpx.ReadError):
218
+ calls = []
219
+ def handle(request):
220
+ calls.append(request)
221
+ raise error_type("injected")
222
+ with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(handle)) as client:
223
+ with self.assertRaises(RemoteTransportError):
224
+ client.submit(constant, ["a"])
225
+ self.assertEqual(len(calls), 1)
226
+ with self.client(lambda p: Chunks([encode([accepted(p), success(0)]), error_type("injected")])) as client:
227
+ stream = client.submit(constant, ["a", "b"])
228
+ self.assertEqual(next(stream).index, 0)
229
+ with self.assertRaises(RemoteTransportError):
230
+ next(stream)
231
+ self.assertTrue(stream.closed)
232
+ self.assertEqual(len(self.requests), 1)
233
+
234
+ def test_response_headers_and_errors_before_acceptance(self):
235
+ for media_type in (None, "application/json", "text/plain"):
236
+ response = httpx.Response(200, headers={} if media_type is None else {"Content-Type": media_type},
237
+ stream=Chunks([b'{}\n']))
238
+ with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(lambda r: response)) as client:
239
+ with self.assertRaises(RemoteProtocolError):
240
+ client.submit(constant, ["a"])
241
+ self.assertTrue(response.is_closed)
242
+ with self.client(lambda p: [{"type": "error", "error": {"code": "unsupported_ir", "message": "version"}}]) as client:
243
+ with self.assertRaises(RemoteSubmissionError) as caught:
244
+ list(client.submit(constant, ["a"]))
245
+ self.assertEqual(caught.exception.code, "unsupported_ir")
246
+ response = httpx.Response(503, headers={"Retry-After": "10"}, content="<html>private debug details</html>")
247
+ with AnnoRemoteClient("http://offline.test", transport=httpx.MockTransport(lambda r: response)) as client:
248
+ with self.assertRaises(RemoteSubmissionError) as caught:
249
+ client.submit(constant, ["a"])
250
+ self.assertEqual(caught.exception.http_status, 503)
251
+ self.assertEqual(caught.exception.retry_after, "10")
252
+ self.assertNotIn("private", str(caught.exception))
253
+
254
+ def test_close_is_lazy_idempotent_and_client_closes_all_streams(self):
255
+ def frames(p):
256
+ return Chunks([encode([accepted(p)]), encode([success(0)]),
257
+ encode([success(1)]), encode([{"type": "done"}])])
258
+ with self.client(frames) as client:
259
+ a = client.submit(constant, ["a", "b"])
260
+ self.assertEqual(self.responses[0].pulled, 0)
261
+ self.assertEqual(next(a).index, 0)
262
+ self.assertEqual(self.responses[0].pulled, 2)
263
+ a.close()
264
+ a.close()
265
+ self.assertEqual(self.responses[0].pulled, 2)
266
+ b = client.submit(constant, ["a", "b"])
267
+ client.close()
268
+ self.assertTrue(b.closed)
269
+ self.assertEqual(list(a), [])
270
+ self.assertEqual(list(b), [])
271
+ self.assertTrue(all(response.closed for response in self.responses))
272
+ with self.assertRaises(RuntimeError):
273
+ client.submit(constant, [])
274
+
275
+ def test_cleanup_failure_still_closes_other_streams(self):
276
+ client = self.client()
277
+ streams = [client.submit(constant, ["a"]) for _ in range(2)]
278
+ with patch.object(self.responses[0], "close", side_effect=RuntimeError("cleanup failed")):
279
+ with self.assertRaisesRegex(RuntimeError, "cleanup failed"):
280
+ client.close()
281
+ self.assertTrue(all(stream.closed for stream in streams))
282
+ self.assertTrue(self.responses[1].closed)
283
+ self.assertTrue(client._http.is_closed)
284
+
285
+ def test_input_and_configuration_types(self):
286
+ for options in ({"mode": "other"}, {"optimize": 1}, {"timeout": 0}, {"timeout": True},
287
+ {"timeout": float("inf")}, {"auth_token": "a\nb"}):
288
+ with self.subTest(options=options), self.assertRaises((ValueError, TypeError)):
289
+ AnnoRemoteClient("http://offline.test", **options)
290
+ for url in ("file:///tmp/x", "http://u:p@host", "http://host/?token=x", "http://host/#x"):
291
+ with self.assertRaises(ValueError):
292
+ AnnoRemoteClient(url)
293
+ with self.client() as client:
294
+ with self.assertRaises(TypeError):
295
+ client.submit(constant, ("a",))
296
+ with self.assertRaises(TypeError):
297
+ client.submit(constant, ["a"], ordered=1)
298
+
299
+
300
+ class ImportBoundaryTests(unittest.TestCase):
301
+ def run_isolated(self, code):
302
+ result = subprocess.run([sys.executable, "-B", "-c", code], capture_output=True, text=True,
303
+ cwd=Path(__file__).resolve().parents[1], timeout=30)
304
+ self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
305
+
306
+
307
+ if __name__ == "__main__":
308
+ unittest.main()
@@ -0,0 +1,295 @@
1
+ """Frozen pre-refactor wire compatibility, explicit binding and offline preview."""
2
+
3
+ from copy import deepcopy
4
+ import hashlib
5
+ import io
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+ import tempfile
10
+ import unittest
11
+ from unittest.mock import patch
12
+
13
+ import httpx
14
+
15
+ from annoscript._data import thaw
16
+ from annoscript._request_adapters import DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER
17
+ from annoscript.backends import DecisionAPIBackend, LLMAPIBackend, InferenceContext, InferenceError
18
+ from annoscript.backends._batch_wire import batch_calls
19
+ from annoscript.backends.base import normalize_call, encode_payload
20
+ from annoscript.backends.config import backends_from_env
21
+ from annoscript.client import AnnoClient
22
+ from annoscript.preview import preview_request, main
23
+ from annoscript.server.releases import Releases, manifest
24
+ from annoscript.server.worker import backends as worker_backends
25
+
26
+ BASELINE = Path(__file__).with_name('fixtures') / 'request-adapters-v1.json'
27
+
28
+
29
+ def configuration():
30
+ return {'decision': {'base_url': 'http://offline.invalid/decision', 'model': 'unchanged-decider', 'revision': 'fixed'},
31
+ 'generation': {'base_url': 'http://offline.invalid/v1', 'model': 'unchanged-generator', 'revision': 'fixed'}}
32
+
33
+
34
+ def spec(stage='generation', **extra):
35
+ value = {'format': 'lev-request-spec', 'version': 1, 'stage': stage, 'model': 'unchanged-model',
36
+ 'state': {'text': '北京广州'}, 'calls': [{'id': 'x', 'operation': 'generate',
37
+ 'arguments': {'instructions': '标题', 'max_length': 24}}]}
38
+ if stage == 'decision':
39
+ value['calls'][0].update(operation='choice', arguments={'instructions': '类别', 'criteria': {'甲': '甲类', '乙': '乙类'}})
40
+ value.update(extra)
41
+ return value
42
+
43
+
44
+ def answer(call):
45
+ if call.operation == 'generate':
46
+ return '标题'
47
+ if call.operation == 'generate_list':
48
+ return ['维度']
49
+ return ['北京']
50
+
51
+
52
+ class FrozenWireTests(unittest.IsolatedAsyncioTestCase):
53
+ async def test_111_pre_refactor_bodies_default_and_explicit_on_real_send_path(self):
54
+ fixture = json.loads(BASELINE.read_text())
55
+ self.assertEqual(fixture['source_head'], 'synthetic-request-adapter-v1')
56
+ self.assertEqual(len(fixture['cases']), 111)
57
+ for index, case in enumerate(fixture['cases']):
58
+ for explicit in (False, True):
59
+ with self.subTest(case=index, explicit=explicit):
60
+ context = InferenceContext(case['state'], case.get('system_prompt'))
61
+ calls = [normalize_call(x['id'], x['operation'], (), x['kwargs'], context) for x in case['calls']]
62
+ original = [thaw(c.arguments) for c in calls]
63
+ received = []
64
+
65
+ def handle(request):
66
+ received.append(request.content)
67
+ body = json.loads(request.content)
68
+ if case['stage'] == 'decision':
69
+ answers = {}
70
+ for key, q in body['questions'].items():
71
+ value = {'type': q['type']}
72
+ if q['type'] == 'noul':
73
+ value['noul'] = .8
74
+ elif q['type'] == 'choice':
75
+ labels = list(q['criteria'])
76
+ value.update(choice=labels[0], confidence=1., probabilities={k: float(k == labels[0]) for k in labels})
77
+ else:
78
+ value.update(score=1., confidence=1., probabilities={'0': 0., '1': 1.},
79
+ legend={str(i): v for i, v in enumerate(q['criteria'])})
80
+ answers[key] = value
81
+ data = {'answers': answers}
82
+ elif case['mode'] == 'batch':
83
+ text = json.dumps({'answers': {k: answer(c) for k, c in batch_calls(calls).items()}}, ensure_ascii=False)
84
+ data = {'choices': [{'finish_reason': 'stop', 'message': {'content': text}}]}
85
+ else:
86
+ value = answer(calls[0])
87
+ text = (json.dumps(value, ensure_ascii=False) if calls[0].arguments.get('nullable') else value) if calls[0].operation == 'generate' else json.dumps({'items': value}, ensure_ascii=False)
88
+ data = {'choices': [{'finish_reason': 'stop', 'message': {'content': text}}]}
89
+ return httpx.Response(200, json=data)
90
+
91
+ options = deepcopy(case['options'])
92
+ if explicit:
93
+ options['request_adapter'] = DEFAULT_DECISION_ADAPTER if case['stage'] == 'decision' else DEFAULT_GENERATION_ADAPTER
94
+ if options.get('request_policy'):
95
+ options['_token_counter'] = lambda payload: 10
96
+ cls = DecisionAPIBackend if case['stage'] == 'decision' else LLMAPIBackend
97
+ backend = cls('http://offline.invalid/v1', 'transport-only-secret', fixture['model'],
98
+ transport=httpx.MockTransport(handle), **options)
99
+ try:
100
+ if case['stage'] == 'decision':
101
+ preview = backend.preview_batch(context, calls)
102
+ await backend.infer_batch(context, calls)
103
+ elif case['mode'] == 'batch':
104
+ preview = backend.preview_batch(context, calls, max_tokens=case['max_tokens'])
105
+ await backend.infer_batch(context, calls, max_tokens=case['max_tokens'])
106
+ else:
107
+ # Frozen envelopes used an explicit physical budget. max_num_tokens
108
+ # is absent from the prompt, so pin that budget for the public path.
109
+ item = case['calls'][0]
110
+ pinned = normalize_call(item['id'], item['operation'], (),
111
+ {**item['kwargs'], 'max_num_tokens': case['max_tokens']}, context)
112
+ preview = backend.preview(context, pinned, repair_feedback=case.get('repair_feedback'))
113
+ await backend.infer(context, pinned, repair_feedback=case.get('repair_feedback'))
114
+ self.assertEqual(len(received), 1)
115
+ self.assertEqual(len(received[0]), case['expected_bytes'])
116
+ self.assertEqual(hashlib.sha256(received[0]).hexdigest(), case['expected_sha256'])
117
+ self.assertEqual(encode_payload(preview['payload']), received[0])
118
+ self.assertEqual(preview['body_sha256'], case['expected_sha256'])
119
+ self.assertNotIn('transport-only-secret', json.dumps(preview))
120
+ self.assertEqual([thaw(c.arguments) for c in calls], original)
121
+ finally:
122
+ await backend.aclose()
123
+
124
+ async def test_preview_errors_match_preflight_without_dispatch(self):
125
+ from annoscript.test_generation_policy import policy
126
+ context = InferenceContext('')
127
+ call = normalize_call('e', 'extract', ('来源',), {'min_items': 1, 'max_items': 2, 'max_length_per_item': 4}, context)
128
+ backend = LLMAPIBackend('http://offline.invalid', 'secret', 'model', profile='qwen_vllm',
129
+ transport=httpx.MockTransport(lambda req: self.fail('unexpected HTTP')))
130
+ try:
131
+ for invoke in (lambda: backend.preview(context, call), lambda: backend.preview_batch(context, [call], max_tokens=32)):
132
+ with self.assertRaises(InferenceError) as caught:
133
+ invoke()
134
+ self.assertEqual(caught.exception.code, 'unsatisfiable_constraints')
135
+ with self.assertRaises(InferenceError) as caught:
136
+ await backend.infer(context, call)
137
+ self.assertEqual(caught.exception.code, 'unsatisfiable_constraints')
138
+ self.assertIsNone(backend._client)
139
+ finally:
140
+ await backend.aclose()
141
+ context = InferenceContext('北京')
142
+ call = normalize_call('e', 'generate', ('标题',), {'max_length': 4}, context)
143
+ backend = LLMAPIBackend('http://offline.invalid', 'secret', 'model', profile='qwen_vllm',
144
+ request_policy=policy(), _token_counter=lambda p: 8190,
145
+ transport=httpx.MockTransport(lambda req: self.fail('unexpected HTTP')))
146
+ try:
147
+ with self.assertRaises(InferenceError) as caught:
148
+ backend.preview(context, call)
149
+ self.assertEqual(caught.exception.code, 'context_length_exceeded')
150
+ with self.assertRaises(InferenceError):
151
+ await backend.infer(context, call)
152
+ self.assertIsNone(backend._client)
153
+ finally:
154
+ await backend.aclose()
155
+
156
+
157
+ class AdapterBindingTests(unittest.TestCase):
158
+ def test_release_materializes_defaults_and_rejects_unbound_or_cross_stage_recipes(self):
159
+ cfg = configuration()
160
+ implicit = manifest(cfg)
161
+ explicit = deepcopy(cfg)
162
+ explicit['decision']['request_adapter'] = DEFAULT_DECISION_ADAPTER
163
+ explicit['generation']['request_adapter'] = DEFAULT_GENERATION_ADAPTER
164
+ self.assertEqual(implicit, manifest(explicit))
165
+ self.assertEqual(implicit['decision']['request_adapter'], DEFAULT_DECISION_ADAPTER)
166
+ self.assertEqual(implicit['generation']['request_adapter'], DEFAULT_GENERATION_ADAPTER)
167
+ self.assertEqual(cfg, configuration())
168
+ for stage in ('decision', 'generation'):
169
+ for invalid in ('unknown', '', None, False, [], DEFAULT_GENERATION_ADAPTER if stage == 'decision' else DEFAULT_DECISION_ADAPTER):
170
+ altered = deepcopy(cfg)
171
+ altered[stage]['request_adapter'] = invalid
172
+ with self.subTest(stage=stage, invalid=invalid), self.assertRaises(ValueError):
173
+ manifest(altered)
174
+
175
+ def test_adapter_implementation_changes_release_identity_and_existing_job_stays_bound(self):
176
+ from annoscript.server.store import Store
177
+ from annoscript.test_jobs_server import payload
178
+ cfg = {'test': configuration()}
179
+ old = Releases(cfg, 'test')
180
+ read_bytes = Path.read_bytes
181
+
182
+ def changed_adapter(path):
183
+ data = read_bytes(path)
184
+ return data + b'\n# revision\n' if path.parent.name == 'adapters' and path.name == 'decision.py' else data
185
+
186
+ with patch.object(Path, 'read_bytes', changed_adapter):
187
+ new = Releases(cfg, 'test')
188
+ self.assertNotEqual(old.default, new.default)
189
+ with tempfile.TemporaryDirectory() as folder:
190
+ store = Store(Path(folder) / 'jobs.sqlite')
191
+ first = store.submit('alice', 'same', payload(), old)
192
+ repeated = store.submit('alice', 'same', payload(), new)
193
+ fresh = store.submit('alice', 'fresh', payload(), new)
194
+ self.assertEqual(first['release_id'], repeated['release_id'])
195
+ self.assertEqual(fresh['release_id'], new.default)
196
+
197
+ def test_worker_uses_bound_adapter_despite_poisoned_environment(self):
198
+ cfg = manifest(configuration())
199
+ environment = {'ANNO_DECISION_AUTH_TOKEN': 'secret', 'ANNO_GENERATE_AUTH_TOKEN': 'secret',
200
+ 'ANNO_DECISION_REQUEST_ADAPTER': 'unknown', 'ANNO_GENERATE_REQUEST_ADAPTER': 'unknown'}
201
+ with patch.dict(os.environ, environment, clear=True):
202
+ decision, generation = worker_backends(cfg)
203
+ self.assertEqual(decision.request_adapter, DEFAULT_DECISION_ADAPTER)
204
+ self.assertEqual(generation.request_adapter, DEFAULT_GENERATION_ADAPTER)
205
+ # Existing manifests without recipe fields retain the original recipe.
206
+ cfg['decision'].pop('request_adapter')
207
+ cfg['generation'].pop('request_adapter')
208
+ with patch.dict(os.environ, environment, clear=True):
209
+ decision, generation = worker_backends(cfg)
210
+ self.assertEqual(decision.request_adapter, DEFAULT_DECISION_ADAPTER)
211
+ self.assertEqual(generation.request_adapter, DEFAULT_GENERATION_ADAPTER)
212
+
213
+ def test_environment_precedence_and_client_forwarding(self):
214
+ env = {f'ANNO_{p}_{k}': v for p in ('DECISION', 'GENERATE') for k, v in
215
+ [('BASE_URL', 'http://offline.invalid'), ('AUTH_TOKEN', 'secret'), ('MODEL', 'same-model')]}
216
+ with tempfile.TemporaryDirectory() as folder:
217
+ path = Path(folder) / 'env'
218
+ path.write_text('ANNO_DECISION_REQUEST_ADAPTER=unknown\nANNO_GENERATE_REQUEST_ADAPTER=unknown\n')
219
+ with patch.dict(os.environ, env, clear=True):
220
+ d, g = backends_from_env()
221
+ self.assertEqual((d.request_adapter, g.request_adapter), (DEFAULT_DECISION_ADAPTER, DEFAULT_GENERATION_ADAPTER))
222
+ with self.assertRaises(ValueError):
223
+ backends_from_env(env_file=path)
224
+ with AnnoClient.from_env(env_file=path, decision_request_adapter=DEFAULT_DECISION_ADAPTER,
225
+ generation_request_adapter=DEFAULT_GENERATION_ADAPTER) as client:
226
+ self.assertEqual(client.runtime.decision_backend.request_adapter, DEFAULT_DECISION_ADAPTER)
227
+ self.assertEqual(client.runtime.llm_backend.request_adapter, DEFAULT_GENERATION_ADAPTER)
228
+ env.update(ANNO_DECISION_REQUEST_ADAPTER=DEFAULT_DECISION_ADAPTER, ANNO_GENERATE_REQUEST_ADAPTER=DEFAULT_GENERATION_ADAPTER)
229
+ with patch.dict(os.environ, env, clear=True):
230
+ self.assertEqual(backends_from_env(env_file=path)[1].request_adapter, DEFAULT_GENERATION_ADAPTER)
231
+ for value in ('', False, 0, []):
232
+ with self.assertRaises(ValueError):
233
+ backends_from_env(generation_request_adapter=value)
234
+ for cls, wrong in [(DecisionAPIBackend, DEFAULT_GENERATION_ADAPTER), (LLMAPIBackend, DEFAULT_DECISION_ADAPTER)]:
235
+ with patch('annoscript.backends.base.HTTPBackend.__init__', side_effect=AssertionError('transport initialized')):
236
+ with self.assertRaises(ValueError):
237
+ cls('http://offline.invalid', 'secret', 'model', request_adapter=wrong)
238
+
239
+
240
+ class OfflinePreviewTests(unittest.TestCase):
241
+ def test_preview_is_deterministic_detached_and_does_not_load_credentials_or_dispatch(self):
242
+ for stage in ('decision', 'generation'):
243
+ request = spec(stage)
244
+ original = deepcopy(request)
245
+ with patch('annoscript.backends.base.HTTPBackend.post', side_effect=AssertionError('HTTP')),\
246
+ patch('annoscript.backends.config.backends_from_env', side_effect=AssertionError('credentials')),\
247
+ patch('socket.socket.connect', side_effect=AssertionError('socket')),\
248
+ patch.dict(os.environ, {'ANNO_DECISION_REQUEST_ADAPTER': 'unknown', 'ANNO_GENERATE_REQUEST_ADAPTER': 'unknown'}, clear=True):
249
+ left, right = preview_request(request), preview_request(request)
250
+ self.assertEqual(left, right)
251
+ self.assertEqual(request, original)
252
+ self.assertEqual(hashlib.sha256(encode_payload(left['payload'])).hexdigest(), left['body_sha256'])
253
+ left['payload']['model'] = 'modified-preview'
254
+ self.assertEqual(preview_request(request), right)
255
+
256
+ def test_invalid_specs_and_collisions_are_rejected(self):
257
+ cases = [spec(version=True), spec(stage=[]), spec(options={'auth_token': 'secret'}),
258
+ spec(request_adapter=DEFAULT_DECISION_ADAPTER), spec(mode='single', calls=[]),
259
+ spec(mode='batch'), spec(max_tokens=0), spec(repair_feedback=None), spec(unrecognized=True)]
260
+ for change in [lambda v: v['calls'].append(deepcopy(v['calls'][0])),
261
+ lambda v: v['calls'][0].update(id=''),
262
+ lambda v: v['calls'][0].update(arguments={'instructions': 'x', 'source': 'old-api'})]:
263
+ v = spec(); change(v); cases.append(v)
264
+ collision = spec('decision')
265
+ collision['calls'][0]['arguments']['field_name'] = 'same'
266
+ other = deepcopy(collision['calls'][0]); other['id'] = 'y'
267
+ collision['calls'].append(other); cases.append(collision)
268
+ cross_stage = spec(mode='batch', max_tokens=64)
269
+ cross_stage['calls'] = spec('decision')['calls']
270
+ cases.append(cross_stage)
271
+ for case in cases:
272
+ with self.subTest(case=case), self.assertRaises((ValueError, TypeError)):
273
+ preview_request(case)
274
+
275
+ def test_cli_files_stdin_strict_json_and_input_protection(self):
276
+ with tempfile.TemporaryDirectory() as folder:
277
+ source, target = Path(folder) / 'spec.json', Path(folder) / 'preview.json'
278
+ raw = json.dumps(spec(), ensure_ascii=False)
279
+ source.write_text(raw)
280
+ self.assertEqual(main([str(source), '-o', str(target)]), 0)
281
+ self.assertEqual(json.loads(target.read_text()), preview_request(spec()))
282
+ with patch('sys.stderr', new_callable=io.StringIO):
283
+ self.assertEqual(main([str(source), '-o', str(source)]), 2)
284
+ self.assertEqual(source.read_text(), raw)
285
+ with patch('sys.stdin', io.StringIO(raw)), patch('sys.stdout', new_callable=io.StringIO) as out:
286
+ self.assertEqual(main(['-']), 0)
287
+ self.assertEqual(json.loads(out.getvalue()), preview_request(spec()))
288
+ for raw in ['{"version":1,"version":1}', '{"x":NaN}']:
289
+ source.write_text(raw)
290
+ with patch('sys.stderr', new_callable=io.StringIO):
291
+ self.assertEqual(main([str(source)]), 2)
292
+
293
+
294
+ if __name__ == '__main__':
295
+ unittest.main()