feat(registry): mutually-exclusive sampling_override frozen into run snapshots

This commit is contained in:
m4
2026-07-30 10:08:26 +08:00
parent 8eff551bb6
commit 08aa0d0e05
6 changed files with 145 additions and 76 deletions
+31 -18
View File
@@ -46,6 +46,7 @@ from .schemas import (
ProviderConfig,
ReasoningEffort,
ResolvedModelConfig,
SamplingOverride,
)
SUPPORTED_ADAPTER_IDS = (
@@ -583,8 +584,7 @@ def resolve_parameters(
spec: AdapterParameterSpec,
*,
reasoning_effort_override: ReasoningEffort | None = None,
temperature_override: float | None = None,
top_p_override: float | None = None,
sampling_override: SamplingOverride | None = None,
) -> ResolvedParameters:
"""Resolve a model's runtime parameters against the matched contract.
@@ -594,8 +594,10 @@ def resolve_parameters(
and the save-time contract checks. Raises ``ModelRegistryError`` with a
stable section 9.5 code on any violation.
The three overrides (snapshot creation only) replace the model's
configured values before inheritance; they flow through the same
The sampling override (snapshot creation only) replaces the model's
configured value for its ``kind`` and omits the other sampling
parameter entirely — registry defaults included — because temperature
and top_p must not be sent together. Overrides flow through the same
contract rules, so unsupported adapters still reject them.
"""
auth_spec = spec.auth_specs.get(provider.auth.mode)
@@ -645,21 +647,32 @@ def resolve_parameters(
model.runtime.max_output_tokens,
)
temperature = (
temperature_override
if temperature_override is not None
else model.runtime.temperature
)
if temperature is None:
temperature = provider.runtime.default_temperature
temperature = _resolve_nullable_parameter(
"temperature", _parameter_rule(spec, "temperature"), temperature
)
if sampling_override is not None and sampling_override.kind == "temperature":
temperature = _resolve_nullable_parameter(
"temperature", _parameter_rule(spec, "temperature"), sampling_override.value
)
top_p = _resolve_nullable_parameter(
"top_p", _parameter_rule(spec, "top_p"), None
)
elif sampling_override is not None:
top_p = _resolve_nullable_parameter(
"top_p", _parameter_rule(spec, "top_p"), sampling_override.value
)
temperature = _resolve_nullable_parameter(
"temperature", _parameter_rule(spec, "temperature"), None
)
else:
temperature = model.runtime.temperature
if temperature is None:
temperature = provider.runtime.default_temperature
temperature = _resolve_nullable_parameter(
"temperature", _parameter_rule(spec, "temperature"), temperature
)
top_p = top_p_override if top_p_override is not None else model.runtime.top_p
if top_p is None:
top_p = provider.runtime.default_top_p
top_p = _resolve_nullable_parameter("top_p", _parameter_rule(spec, "top_p"), top_p)
top_p = model.runtime.top_p
if top_p is None:
top_p = provider.runtime.default_top_p
top_p = _resolve_nullable_parameter("top_p", _parameter_rule(spec, "top_p"), top_p)
reasoning_effort: ReasoningEffort = (
reasoning_effort_override
+26 -20
View File
@@ -1175,6 +1175,29 @@
"title": "RuntimeOptionsPublic",
"type": "object"
},
"SamplingOverride": {
"description": "Mutually-exclusive per-run sampling override (2026-07-30 design).\n\nOverriding one parameter omits the other \u2014 including its registry\ndefault \u2014 from the request, because providers reject or misbehave when\ntemperature and top_p are set together.",
"properties": {
"kind": {
"enum": [
"temperature",
"top_p"
],
"title": "Kind",
"type": "string"
},
"value": {
"title": "Value",
"type": "number"
}
},
"required": [
"kind",
"value"
],
"title": "SamplingOverride",
"type": "object"
},
"SelectableModel": {
"description": "One entry of the section 9.1 model selector response.",
"properties": {
@@ -1289,38 +1312,21 @@
"title": "Run Request Id",
"type": "string"
},
"temperature": {
"sampling_override": {
"anyOf": [
{
"maximum": 2,
"minimum": 0,
"type": "number"
"$ref": "#/components/schemas/SamplingOverride"
},
{
"type": "null"
}
],
"default": null,
"title": "Temperature"
"default": null
},
"thread_id": {
"minLength": 1,
"title": "Thread Id",
"type": "string"
},
"top_p": {
"anyOf": [
{
"exclusiveMinimum": 0,
"maximum": 1,
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Top P"
}
},
"required": [
+5 -8
View File
@@ -54,6 +54,7 @@ from .schemas import (
RegistryV4,
RequestOptions,
ResolvedModelConfig,
SamplingOverride,
VerificationInfo,
)
from .store import ModelRuntimeStore
@@ -104,8 +105,7 @@ class ModelRegistryResolver:
*,
registry: RegistryV4 | None = None,
reasoning_effort_override: ReasoningEffort | None = None,
temperature_override: float | None = None,
top_p_override: float | None = None,
sampling_override: SamplingOverride | None = None,
) -> ResolvedModelConfig:
"""Resolve an enabled, verified model into its frozen run config."""
return self._resolve(
@@ -114,8 +114,7 @@ class ModelRegistryResolver:
registry=registry,
require_enabled=True,
reasoning_effort_override=reasoning_effort_override,
temperature_override=temperature_override,
top_p_override=top_p_override,
sampling_override=sampling_override,
)
def resolve_for_test(self, model_ref: ModelRef) -> ResolvedModelConfig:
@@ -143,8 +142,7 @@ class ModelRegistryResolver:
require_enabled: bool,
require_verified: bool = True,
reasoning_effort_override: ReasoningEffort | None = None,
temperature_override: float | None = None,
top_p_override: float | None = None,
sampling_override: SamplingOverride | None = None,
) -> ResolvedModelConfig:
_check_role(role)
if registry is None:
@@ -196,8 +194,7 @@ class ModelRegistryResolver:
model,
spec,
reasoning_effort_override=reasoning_effort_override,
temperature_override=temperature_override,
top_p_override=top_p_override,
sampling_override=sampling_override,
)
auth_spec = spec.auth_specs[provider.auth.mode]
+23
View File
@@ -6,6 +6,7 @@ these Pydantic models instead of maintaining separate shapes.
from __future__ import annotations
import math
from typing import Annotated, Literal
from pydantic import (
@@ -48,6 +49,28 @@ LimitsSource = Literal["provider", "tested_contract", "user"]
ModelRole = Literal["primary"]
class SamplingOverride(BaseModel):
"""Mutually-exclusive per-run sampling override (2026-07-30 design).
Overriding one parameter omits the other — including its registry
default — from the request, because providers reject or misbehave when
temperature and top_p are set together.
"""
kind: Literal["temperature", "top_p"]
value: float
@model_validator(mode="after")
def _check_value_range(self) -> SamplingOverride:
if not math.isfinite(self.value):
raise ValueError("sampling override value must be finite")
if self.kind == "temperature" and not 0 <= self.value <= 2:
raise ValueError("temperature override must be within [0, 2]")
if self.kind == "top_p" and not 0 < self.value <= 1:
raise ValueError("top_p override must be within (0, 1]")
return self
class _FrozenModel(BaseModel):
model_config = ConfigDict(frozen=True)
+16 -15
View File
@@ -11,7 +11,7 @@ into ``run_runtime_snapshots.payload_json``. Payloads and logs never carry
maps to at most one non-terminal snapshot. A repeated request with the
same ``selection_hash`` returns the original snapshot; a different hash
raises ``RUN_REQUEST_CONFLICT``. ``selection_hash`` covers the
pre-resolution ``{primary, reasoning_effort, temperature, top_p}`` selection (inherit
pre-resolution ``{primary, reasoning_effort, sampling_override}`` selection (inherit
participates as ``null``); ``model_selection_revision`` is an audit field
and never part of the hash.
- Lifecycle: ``prepared`` (TTL 15 minutes) → ``bound`` (retained 24 hours
@@ -34,7 +34,7 @@ import time
import uuid
from typing import Any, Literal, get_args
from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt
from pydantic import BaseModel, NonNegativeInt, PositiveInt
from .adapters import find_adapter_spec
from .errors import (
@@ -53,6 +53,7 @@ from .schemas import (
NonEmptyString,
ReasoningEffort,
ResolvedModelConfig,
SamplingOverride,
)
from .store import ModelRuntimeStore
@@ -76,11 +77,11 @@ class SnapshotCreateRequest(BaseModel):
# ``None``/``auto`` keeps the registry-configured effort. Unsupported
# adapters reject it.
reasoning_effort: ReasoningEffort | None = None
# Per-run generation overrides (thread-level selection); ``None`` keeps
# the registry-configured values. Out-of-contract values are rejected by
# the adapter rules at resolve time.
temperature: float | None = Field(default=None, ge=0, le=2)
top_p: float | None = Field(default=None, gt=0, le=1)
# Mutually-exclusive per-run sampling override; ``None`` keeps the
# registry-configured values. Overriding one omits the other from the
# request. Out-of-contract values are rejected by the adapter rules at
# resolve time.
sampling_override: SamplingOverride | None = None
class SnapshotPayload(BaseModel):
@@ -131,8 +132,7 @@ class SnapshotCreation(BaseModel):
def compute_selection_hash(
primary: ModelRef | None,
reasoning_effort: ReasoningEffort | None = None,
temperature: float | None = None,
top_p: float | None = None,
sampling_override: SamplingOverride | None = None,
) -> str:
"""Hash the pre-resolution selection; inherit participates as ``null``."""
encoded = json.dumps(
@@ -143,8 +143,11 @@ def compute_selection_hash(
else {"provider_id": primary.provider_id, "model_key": primary.model_key}
),
"reasoning_effort": reasoning_effort,
"temperature": temperature,
"top_p": top_p,
"sampling_override": (
None
if sampling_override is None
else {"kind": sampling_override.kind, "value": sampling_override.value}
),
},
sort_keys=True,
separators=(",", ":"),
@@ -209,8 +212,7 @@ class SnapshotService:
selection_hash = compute_selection_hash(
request.primary,
request.reasoning_effort,
request.temperature,
request.top_p,
request.sampling_override,
)
existing = self._store.find_active_run_snapshot(
deployment_id=request.deployment_id,
@@ -243,8 +245,7 @@ class SnapshotService:
"primary",
registry=registry,
reasoning_effort_override=request.reasoning_effort,
temperature_override=request.temperature,
top_p_override=request.top_p,
sampling_override=request.sampling_override,
)
payload = {
"registry_revision": registry.revision,
+44 -15
View File
@@ -12,6 +12,7 @@ import json
import time
import pytest
from pydantic import ValidationError
from EvoScientist.model_registry.adapters import adapter_specs, find_adapter_spec
from EvoScientist.model_registry.errors import (
@@ -31,6 +32,7 @@ from EvoScientist.model_registry.schemas import (
CredentialWrite,
ModelRef,
RegistryV4,
SamplingOverride,
)
from EvoScientist.model_registry.snapshots import (
BOUND_RETENTION_SECONDS,
@@ -192,11 +194,11 @@ def _request(**overrides):
# SHA-256 of the canonical JSON
# '{"primary":null,"reasoning_effort":null,"temperature":null,"top_p":null}' —
# '{"primary":null,"reasoning_effort":null,"sampling_override":null}' —
# the inherit selection with no overrides. Pinned as a literal to lock the
# wire contract.
INHERIT_SELECTION_HASH = (
"0e7a49530963e46f999f6ee07e91f3e9b91fca50dfd126b8cc8aebd64055d6cc"
"417399175386cea86c726c1bd2b833bd951066eae2e8f419b99076e9c91724a6"
)
@@ -276,7 +278,7 @@ class TestCreate:
service.create(_request(primary=OLLAMA_REF, reasoning_effort="high"))
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_temperature_top_p_overrides_freeze_into_primary(self, active_store):
def test_sampling_override_freezes_and_omits_the_other(self, active_store):
# glm-5.2's model-specific contract caps temperature at 1, so add a
# generic-contract model on the same provider to exercise the
# override path (disabled → verify → enable, the standard flow).
@@ -304,34 +306,61 @@ class TestCreate:
registry=RegistryV4.model_validate(payload),
)
service = SnapshotService(store, ModelRegistryResolver(store))
ref = {"provider_id": "zhipu-glm", "model_key": "glm-air"}
# temperature override: top_p (provider default 0.95) is omitted.
creation = service.create(
_request(
primary={"provider_id": "zhipu-glm", "model_key": "glm-air"},
temperature=1.1,
top_p=0.5,
primary=ref,
sampling_override={"kind": "temperature", "value": 1.1},
)
)
options = creation.snapshot.payload.primary.request_options
assert options.temperature == 1.1
assert options.top_p is None
# top_p override: temperature (provider default 0.7) is omitted.
creation = service.create(
_request(
run_request_id="req-2",
primary=ref,
sampling_override={"kind": "top_p", "value": 0.5},
)
)
options = creation.snapshot.payload.primary.request_options
assert options.top_p == 0.5
assert options.temperature is None
def test_sampling_override_value_range_enforced(self):
for bad in (
{"kind": "temperature", "value": -0.1},
{"kind": "temperature", "value": 2.1},
{"kind": "temperature", "value": float("nan")},
{"kind": "top_p", "value": 0},
{"kind": "top_p", "value": 1.01},
):
with pytest.raises(ValidationError):
SamplingOverride.model_validate(bad)
def test_overrides_rejected_outside_model_contract(self, service):
# glm-5.2 走模型专属契约:temperature 上限 1;1.5 通过请求模型
# (0–2) 校验但被 adapter 契约拒绝。
with pytest.raises(ModelRegistryError) as excinfo:
service.create(_request(temperature=1.5))
service.create(
_request(sampling_override={"kind": "temperature", "value": 1.5})
)
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_temperature_top_p_overrides_change_selection_hash(self, service):
def test_sampling_override_changes_selection_hash(self, service):
plain = service.create(_request())
assert compute_selection_hash(None, None, 0.5, None) != (
plain.snapshot.selection_hash
)
assert compute_selection_hash(None, None, None, 0.9) != (
plain.snapshot.selection_hash
)
assert compute_selection_hash(
None, None, SamplingOverride(kind="temperature", value=0.5)
) != plain.snapshot.selection_hash
assert compute_selection_hash(
None, None, SamplingOverride(kind="top_p", value=0.9)
) != plain.snapshot.selection_hash
# 未覆盖(None)与缺省一致:不传覆盖时 hash 与旧语义相同。
assert compute_selection_hash(None, None, None, None) == (
assert compute_selection_hash(None, None, None) == (
plain.snapshot.selection_hash
)