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