jev-compatible-server 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.
@@ -0,0 +1,283 @@
1
+ """Configurable model registry for models with stale or incomplete metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections import defaultdict
7
+ from collections.abc import Mapping, Sequence
8
+ from importlib import resources
9
+ from pathlib import Path
10
+ from typing import Any, Literal
11
+
12
+ from pydantic import BaseModel, ConfigDict, Field
13
+
14
+ from .backends import (
15
+ LlamaBackend,
16
+ PointerTransformersBackend,
17
+ TransformersBackend,
18
+ load_decision_config,
19
+ )
20
+ from .bosun import BosunDecisionBackend
21
+ from .causal_options import CausalOptionsBackend
22
+ from .classifier_adapters import GLiClassCalibratedBackend, NLIEntailmentBackend
23
+ from .cross_encoder import CrossEncoderBackend
24
+ from .custom_heads import OpenJevScalarHeadBackend, SmallJevSemanticBackend
25
+ from .encoder_decoder import EncoderDecoderMarginBackend, decision_metadata
26
+ from .gliner2 import GLiNER2Runtime
27
+ from .hidden_state_probe import HiddenStateProbeBackend
28
+ from .laya import LayaBackend
29
+ from .native_systemone import (
30
+ DjevHTTPRuntime,
31
+ DjevThinkingRuntime,
32
+ JeffHTTPRuntime,
33
+ OpenJevThinkingHTTPRuntime,
34
+ WinnowHTTPRuntime,
35
+ )
36
+ from .protocol import DecisionRequest, DecisionResponse, UnsupportedAnswer, Usage
37
+ from .runtime import (
38
+ DecisionRuntime,
39
+ RuntimeErrorBase,
40
+ apply_question_type_support,
41
+ configured_question_types,
42
+ )
43
+ from .sequence_classifier import SequenceClassifierMarginBackend
44
+
45
+
46
+ class RegistryModel(BaseModel):
47
+ """One deployable model and its service-owned decision metadata."""
48
+
49
+ model_config = ConfigDict(extra="forbid")
50
+
51
+ backend: Literal["llama", "transformers", "mlx"]
52
+ model: str
53
+ recipe: str | None = None
54
+ config: dict[str, Any] = Field(default_factory=dict)
55
+ config_path: str | None = None
56
+ enabled: bool = True
57
+ description: str | None = None
58
+ support_status: Literal["supported", "pending"] = "supported"
59
+
60
+ def resolved_config(
61
+ self, recipe_config: dict[str, Any] | None = None
62
+ ) -> dict[str, Any]:
63
+ resolved = _merge_config({}, recipe_config or {})
64
+ file_config = load_decision_config(self.config_path) if self.config_path else {}
65
+ # Registry values win over model-published metadata and file defaults.
66
+ resolved = _merge_config(resolved, file_config)
67
+ return _merge_config(resolved, self.config)
68
+
69
+
70
+ class RegistryFile(BaseModel):
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ default: str | None = None
74
+ recipes: dict[str, dict[str, Any]] = Field(default_factory=dict)
75
+ models: dict[str, RegistryModel] = Field(min_length=1)
76
+
77
+
78
+ def _merge_config(base: dict[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
79
+ result = dict(base)
80
+ for key, value in override.items():
81
+ existing = result.get(key)
82
+ if isinstance(existing, dict) and isinstance(value, dict):
83
+ result[key] = _merge_config(existing, value)
84
+ else:
85
+ result[key] = value
86
+ return result
87
+
88
+
89
+ class ModelRegistry:
90
+ def __init__(self, definition: RegistryFile):
91
+ self.definition = definition
92
+
93
+ @classmethod
94
+ def from_file(cls, path: str | Path) -> ModelRegistry:
95
+ return cls.from_json(Path(path).read_text())
96
+
97
+ @classmethod
98
+ def from_builtin(cls) -> ModelRegistry:
99
+ resource = resources.files("jev_compatible_server").joinpath("public-models.json")
100
+ try:
101
+ value = resource.read_text()
102
+ except FileNotFoundError:
103
+ source_path = Path(__file__).parents[2] / "configs" / "public-models.json"
104
+ value = source_path.read_text()
105
+ return cls.from_json(value)
106
+
107
+ @classmethod
108
+ def from_json(cls, value: str) -> ModelRegistry:
109
+ raw = json.loads(value)
110
+ if not isinstance(raw, dict):
111
+ raise RuntimeErrorBase("model registry must be a JSON object")
112
+ try:
113
+ definition = RegistryFile.model_validate(raw)
114
+ except ValueError as exc:
115
+ raise RuntimeErrorBase(f"invalid model registry: {exc}") from exc
116
+ if definition.default is not None and definition.default not in definition.models:
117
+ raise RuntimeErrorBase(f"registry default is not registered: {definition.default}")
118
+ for name, entry in definition.models.items():
119
+ if entry.recipe is not None and entry.recipe not in definition.recipes:
120
+ raise RuntimeErrorBase(
121
+ f"model {name!r} references unregistered recipe: {entry.recipe}"
122
+ )
123
+ return cls(definition)
124
+
125
+ def resolve(self, requested: str | None) -> tuple[str, RegistryModel]:
126
+ name = requested or self.definition.default
127
+ if name is None:
128
+ raise RuntimeErrorBase("request must specify model or registry needs a default")
129
+ entry = self.definition.models.get(name)
130
+ if entry is None:
131
+ raise RuntimeErrorBase(f"model is not registered: {name}")
132
+ if not entry.enabled:
133
+ raise RuntimeErrorBase(f"model is disabled: {name}")
134
+ return name, entry
135
+
136
+
137
+ def build_transformers_runtime(
138
+ model_id: str,
139
+ config: dict[str, Any],
140
+ *,
141
+ batch_size_override: int | None = None,
142
+ ) -> DecisionRuntime:
143
+ """Select a Transformers readout from metadata, never from a model name."""
144
+
145
+ effective_config = dict(config)
146
+ if batch_size_override is not None:
147
+ if isinstance(batch_size_override, bool) or batch_size_override <= 0:
148
+ raise RuntimeErrorBase("model batch size override must be a positive integer")
149
+ # Dotted metadata has highest precedence in decision_metadata(), so this
150
+ # overrides both nested and dotted registry recipe values without
151
+ # mutating the registry's reusable configuration.
152
+ effective_config["decision.batch_size"] = batch_size_override
153
+
154
+ readout = decision_metadata(effective_config).get("readout")
155
+ if readout == "pointer_head":
156
+ return PointerTransformersBackend(model_id, config=effective_config)
157
+ if readout == "encoder_decoder_margin":
158
+ return EncoderDecoderMarginBackend(model_id, config=effective_config)
159
+ if readout == "sequence_classifier_margin":
160
+ return SequenceClassifierMarginBackend(model_id, config=effective_config)
161
+ if readout == "cross_encoder_margin":
162
+ return CrossEncoderBackend(model_id, config=effective_config)
163
+ if readout == "causal_options":
164
+ return CausalOptionsBackend(model_id, config=effective_config)
165
+ if readout == "bosun_decision_tokens":
166
+ return BosunDecisionBackend(model_id, config=effective_config)
167
+ if readout == "nli_entailment":
168
+ return NLIEntailmentBackend(model_id, config=effective_config)
169
+ if readout == "gliclass_calibrated":
170
+ return GLiClassCalibratedBackend(model_id, config=effective_config)
171
+ if readout == "openjev_scalar_head":
172
+ return OpenJevScalarHeadBackend(model_id, config=effective_config)
173
+ if readout == "semantic_option_head":
174
+ return SmallJevSemanticBackend(model_id, config=effective_config)
175
+ if readout == "hidden_state_probe":
176
+ return HiddenStateProbeBackend(model_id, config=effective_config)
177
+ if readout == "laya_native":
178
+ return LayaBackend(model_id, config=effective_config)
179
+ if readout == "diffusion_structured_read":
180
+ return DjevHTTPRuntime(model_id, config=effective_config)
181
+ if readout == "diffusion_thinking_read":
182
+ native_contract = decision_metadata(effective_config).get("native_contract")
183
+ if native_contract == "djev":
184
+ return DjevThinkingRuntime(model_id, config=effective_config)
185
+ if native_contract == "openjev":
186
+ return OpenJevThinkingHTTPRuntime(model_id, config=effective_config)
187
+ raise RuntimeErrorBase(
188
+ "diffusion_thinking_read requires decision.native_contract of 'djev' or 'openjev'"
189
+ )
190
+ if readout == "gliformer_native":
191
+ return JeffHTTPRuntime(model_id, config=effective_config)
192
+ if readout == "gliner2_multilabel":
193
+ return GLiNER2Runtime(model_id, config=effective_config)
194
+ return TransformersBackend(model_id, config=effective_config)
195
+
196
+
197
+ class RegistryRuntime(DecisionRuntime):
198
+ """Lazy, cached runtime dispatch with per-model microbatching."""
199
+
200
+ def __init__(
201
+ self,
202
+ registry: ModelRegistry,
203
+ *,
204
+ model_batch_size: int | None = None,
205
+ ):
206
+ self.registry = registry
207
+ self.model_name = registry.definition.default or "registry"
208
+ self.model_batch_size = model_batch_size
209
+ self._runtimes: dict[str, DecisionRuntime] = {}
210
+
211
+ def _runtime(self, name: str, entry: RegistryModel) -> DecisionRuntime:
212
+ cached = self._runtimes.get(name)
213
+ if cached is not None:
214
+ return cached
215
+ recipe_config = (
216
+ self.registry.definition.recipes.get(entry.recipe, {})
217
+ if entry.recipe is not None
218
+ else {}
219
+ )
220
+ config = entry.resolved_config(recipe_config)
221
+ runtime: DecisionRuntime
222
+ if entry.backend == "llama":
223
+ if decision_metadata(config).get("readout") == "winnow_shared_branch":
224
+ runtime = WinnowHTTPRuntime(entry.model, config=config)
225
+ else:
226
+ runtime = LlamaBackend(entry.model, config=config)
227
+ elif entry.backend == "mlx":
228
+ raise RuntimeErrorBase(
229
+ "the MLX backend is registered but not installed in this service image"
230
+ )
231
+ else:
232
+ runtime = build_transformers_runtime(
233
+ entry.model,
234
+ config,
235
+ batch_size_override=self.model_batch_size,
236
+ )
237
+ runtime = apply_question_type_support(runtime, config)
238
+ self._runtimes[name] = runtime
239
+ return runtime
240
+
241
+ def decide_batch(self, requests: Sequence[DecisionRequest]) -> list[DecisionResponse]:
242
+ grouped: dict[str, list[tuple[int, DecisionRequest]]] = defaultdict(list)
243
+ entries: dict[str, RegistryModel] = {}
244
+ for index, request in enumerate(requests):
245
+ name, entry = self.registry.resolve(request.model)
246
+ grouped[name].append((index, request))
247
+ entries[name] = entry
248
+
249
+ results: list[DecisionResponse | None] = [None] * len(requests)
250
+ for name, indexed_requests in grouped.items():
251
+ entry = entries[name]
252
+ recipe_config = (
253
+ self.registry.definition.recipes.get(entry.recipe, {})
254
+ if entry.recipe is not None
255
+ else {}
256
+ )
257
+ config = entry.resolved_config(recipe_config)
258
+ supported_types = configured_question_types(config)
259
+ supported_set = frozenset(supported_types)
260
+ if not any(
261
+ question.type in supported_set
262
+ for _, request in indexed_requests
263
+ for question in request.questions.values()
264
+ ):
265
+ for index, request in indexed_requests:
266
+ results[index] = DecisionResponse(
267
+ model=entry.model,
268
+ answers={
269
+ question_name: UnsupportedAnswer(
270
+ type="unsupported",
271
+ question_type=question.type,
272
+ supported_types=list(supported_types),
273
+ )
274
+ for question_name, question in request.questions.items()
275
+ },
276
+ usage=Usage(),
277
+ )
278
+ continue
279
+ runtime = self._runtime(name, entries[name])
280
+ responses = runtime.decide_batch([request for _, request in indexed_requests])
281
+ for (index, _), response in zip(indexed_requests, responses, strict=True):
282
+ results[index] = response
283
+ return [result for result in results if result is not None]
@@ -0,0 +1,237 @@
1
+ """Runtime interface and shared decision post-processing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from abc import ABC, abstractmethod
7
+ from collections.abc import Sequence
8
+ from typing import Any, cast
9
+
10
+ from .protocol import (
11
+ ChoiceAnswer,
12
+ ChoiceQuestion,
13
+ DecisionRequest,
14
+ DecisionResponse,
15
+ NoulAnswer,
16
+ NoulQuestion,
17
+ QuestionType,
18
+ ScoreAnswer,
19
+ ScoreQuestion,
20
+ UnsupportedAnswer,
21
+ Usage,
22
+ )
23
+
24
+
25
+ class RuntimeErrorBase(RuntimeError):
26
+ """A user-facing runtime configuration or inference error."""
27
+
28
+
29
+ def softmax(values: Sequence[float]) -> list[float]:
30
+ if not values:
31
+ raise RuntimeErrorBase("cannot normalize an empty score vector")
32
+ peak = max(values)
33
+ exps = [math.exp(value - peak) for value in values]
34
+ total = sum(exps)
35
+ return [value / total for value in exps]
36
+
37
+
38
+ class DecisionRuntime(ABC):
39
+ """Backend contract. Implementations may process the whole batch at once."""
40
+
41
+ model_name: str
42
+
43
+ @abstractmethod
44
+ def decide_batch(
45
+ self, requests: Sequence[DecisionRequest]
46
+ ) -> list[DecisionResponse]:
47
+ """Evaluate requests, preserving input order."""
48
+
49
+ def decide(self, request: DecisionRequest) -> DecisionResponse:
50
+ return self.decide_batch([request])[0]
51
+
52
+
53
+ ALL_QUESTION_TYPES: tuple[QuestionType, ...] = ("choice", "score", "noul")
54
+
55
+
56
+ def configured_question_types(config: dict[str, Any]) -> tuple[QuestionType, ...]:
57
+ """Read a model's declared capability boundary from decision metadata."""
58
+
59
+ nested = config.get("decision", {})
60
+ nested_types = nested.get("question_types") if isinstance(nested, dict) else None
61
+ value = config.get("decision.question_types", nested_types)
62
+ if value is None:
63
+ return ALL_QUESTION_TYPES
64
+ if not isinstance(value, list) or not value:
65
+ raise RuntimeErrorBase("decision.question_types must be a non-empty array")
66
+ if any(item not in ALL_QUESTION_TYPES for item in value):
67
+ raise RuntimeErrorBase(
68
+ "decision.question_types may contain only choice, score, and noul"
69
+ )
70
+ result: list[QuestionType] = []
71
+ for item in value:
72
+ typed_item = cast(QuestionType, item)
73
+ if typed_item not in result:
74
+ result.append(typed_item)
75
+ return tuple(result)
76
+
77
+
78
+ class QuestionTypeRuntime(DecisionRuntime):
79
+ """Return explicit per-question unsupported results around any backend."""
80
+
81
+ def __init__(
82
+ self,
83
+ runtime: DecisionRuntime,
84
+ supported_types: Sequence[QuestionType],
85
+ ) -> None:
86
+ self.runtime = runtime
87
+ self.model_name = runtime.model_name
88
+ self.supported_types = tuple(supported_types)
89
+ self._supported = frozenset(supported_types)
90
+
91
+ def decide_batch(
92
+ self, requests: Sequence[DecisionRequest]
93
+ ) -> list[DecisionResponse]:
94
+ filtered: list[DecisionRequest] = []
95
+ filtered_indices: list[int] = []
96
+ for index, request in enumerate(requests):
97
+ questions = {
98
+ name: question
99
+ for name, question in request.questions.items()
100
+ if question.type in self._supported
101
+ }
102
+ if questions:
103
+ filtered.append(request.model_copy(update={"questions": questions}))
104
+ filtered_indices.append(index)
105
+
106
+ inferred: dict[int, DecisionResponse] = {}
107
+ if filtered:
108
+ responses = self.runtime.decide_batch(filtered)
109
+ if len(responses) != len(filtered):
110
+ raise RuntimeErrorBase("runtime returned the wrong batch length")
111
+ inferred = dict(zip(filtered_indices, responses, strict=True))
112
+
113
+ results: list[DecisionResponse] = []
114
+ for index, request in enumerate(requests):
115
+ response = inferred.get(index)
116
+ supported_answers = response.answers if response is not None else {}
117
+ answers: dict[str, Any] = {}
118
+ for name, question in request.questions.items():
119
+ if question.type in self._supported:
120
+ answer = supported_answers.get(name)
121
+ if answer is None:
122
+ raise RuntimeErrorBase(
123
+ f"runtime did not return answer for question {name!r}"
124
+ )
125
+ answers[name] = answer
126
+ else:
127
+ answers[name] = UnsupportedAnswer(
128
+ type="unsupported",
129
+ question_type=question.type,
130
+ supported_types=list(self.supported_types),
131
+ )
132
+ results.append(
133
+ DecisionResponse(
134
+ model=response.model if response is not None else self.model_name,
135
+ answers=answers,
136
+ usage=response.usage if response is not None else Usage(),
137
+ )
138
+ )
139
+ return results
140
+
141
+
142
+ def apply_question_type_support(
143
+ runtime: DecisionRuntime, config: dict[str, Any]
144
+ ) -> DecisionRuntime:
145
+ supported = configured_question_types(config)
146
+ if supported == ALL_QUESTION_TYPES:
147
+ return runtime
148
+ return QuestionTypeRuntime(runtime, supported)
149
+
150
+
151
+ class TokenLogitRuntime(DecisionRuntime):
152
+ """Shared formatter for backends that expose next-token logits."""
153
+
154
+ def __init__(self, *, model_name: str, config: dict[str, Any] | None = None):
155
+ self.model_name = model_name
156
+ self.config = config or {}
157
+
158
+ def _metadata(self, name: str, legacy_name: str) -> Any:
159
+ value = self.config.get(name, self.config.get(legacy_name))
160
+ if isinstance(value, dict) and name.startswith("decision."):
161
+ return value
162
+ return value
163
+
164
+ def _prompt(self, request: DecisionRequest, question_name: str, question: Any) -> str:
165
+ template = self._metadata("decision.prompt_template", "prompt_template")
166
+ if not isinstance(template, str):
167
+ raise RuntimeErrorBase(
168
+ "model metadata must define a string decision prompt_template"
169
+ )
170
+ return template.format(
171
+ state=request.state,
172
+ question_name=question_name,
173
+ instructions=question.instructions,
174
+ criteria=question.criteria if hasattr(question, "criteria") else "",
175
+ )
176
+
177
+ def _token_id(self, label: str) -> int:
178
+ tokens = self._metadata("decision.tokens", "decision_tokens")
179
+ if isinstance(tokens, str):
180
+ import json
181
+
182
+ try:
183
+ tokens = json.loads(tokens)
184
+ except json.JSONDecodeError as exc:
185
+ raise RuntimeErrorBase("decision.tokens is not valid JSON") from exc
186
+ if not isinstance(tokens, dict) or label not in tokens:
187
+ raise RuntimeErrorBase(f"missing decision token mapping for {label!r}")
188
+ token_id = tokens[label]
189
+ if not isinstance(token_id, int):
190
+ raise RuntimeErrorBase(f"decision token for {label!r} is not an integer")
191
+ return token_id
192
+
193
+ def _answer(self, question: Any, logits: dict[int, float]) -> Any:
194
+ labels = self._labels_for_question(question)
195
+ return self.answer_from_label_scores(
196
+ question, {label: logits[self._token_id(label)] for label in labels}
197
+ )
198
+
199
+ @staticmethod
200
+ def _labels_for_question(question: Any) -> list[str]:
201
+ if isinstance(question, ChoiceQuestion):
202
+ return list(question.criteria)
203
+ if isinstance(question, ScoreQuestion):
204
+ return [str(index) for index in range(len(question.criteria))]
205
+ if isinstance(question, NoulQuestion):
206
+ return ["true", "false"]
207
+ raise RuntimeErrorBase(f"unsupported question type: {type(question).__name__}")
208
+
209
+ def answer_from_label_scores(self, question: Any, scores: dict[str, float]) -> Any:
210
+ if isinstance(question, ChoiceQuestion):
211
+ labels = list(question.criteria)
212
+ probabilities = softmax([scores[label] for label in labels])
213
+ distribution = dict(zip(labels, probabilities, strict=True))
214
+ choice = max(distribution, key=distribution.__getitem__)
215
+ return ChoiceAnswer(
216
+ type="choice",
217
+ choice=choice,
218
+ probabilities=distribution,
219
+ confidence=distribution[choice],
220
+ )
221
+ if isinstance(question, ScoreQuestion):
222
+ labels = [str(index) for index in range(len(question.criteria))]
223
+ probabilities = softmax([scores[label] for label in labels])
224
+ distribution = dict(zip(labels, probabilities, strict=True))
225
+ score = sum(index * probability for index, probability in enumerate(probabilities))
226
+ return ScoreAnswer(
227
+ type="score",
228
+ score=score,
229
+ probabilities=distribution,
230
+ confidence=max(probabilities),
231
+ legend=question.criteria,
232
+ )
233
+ if isinstance(question, NoulQuestion):
234
+ yes = scores["true"]
235
+ no = scores["false"]
236
+ return NoulAnswer(type="noul", noul=1.0 / (1.0 + math.exp(no - yes)))
237
+ raise RuntimeErrorBase(f"unsupported question type: {type(question).__name__}")