feat(registry): freeze per-thread temperature/top_p overrides into run snapshots

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-28 17:23:26 +08:00
parent e57ecd4588
commit ba7d908276
5 changed files with 132 additions and 13 deletions
+11 -5
View File
@@ -583,6 +583,8 @@ def resolve_parameters(
spec: AdapterParameterSpec,
*,
reasoning_effort_override: ReasoningEffort | None = None,
temperature_override: float | None = None,
top_p_override: float | None = None,
) -> ResolvedParameters:
"""Resolve a model's runtime parameters against the matched contract.
@@ -592,9 +594,9 @@ def resolve_parameters(
and the save-time contract checks. Raises ``ModelRegistryError`` with a
stable section 9.5 code on any violation.
``reasoning_effort_override`` (snapshot creation only) replaces the
model's configured effort before inheritance; it flows through the same
contract rule, so unsupported adapters still reject non-``auto`` values.
The three overrides (snapshot creation only) replace the model's
configured values before inheritance; they flow through the same
contract rules, so unsupported adapters still reject them.
"""
auth_spec = spec.auth_specs.get(provider.auth.mode)
if auth_spec is None:
@@ -643,14 +645,18 @@ def resolve_parameters(
model.runtime.max_output_tokens,
)
temperature = model.runtime.temperature
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
)
top_p = model.runtime.top_p
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)
+28
View File
@@ -1265,10 +1265,38 @@
"title": "Run Request Id",
"type": "string"
},
"temperature": {
"anyOf": [
{
"maximum": 2,
"minimum": 0,
"type": "number"
},
{
"type": "null"
}
],
"default": null,
"title": "Temperature"
},
"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": [
+12 -1
View File
@@ -104,6 +104,8 @@ class ModelRegistryResolver:
*,
registry: RegistryV4 | None = None,
reasoning_effort_override: ReasoningEffort | None = None,
temperature_override: float | None = None,
top_p_override: float | None = None,
) -> ResolvedModelConfig:
"""Resolve an enabled, verified model into its frozen run config."""
return self._resolve(
@@ -112,6 +114,8 @@ class ModelRegistryResolver:
registry=registry,
require_enabled=True,
reasoning_effort_override=reasoning_effort_override,
temperature_override=temperature_override,
top_p_override=top_p_override,
)
def resolve_for_test(self, model_ref: ModelRef) -> ResolvedModelConfig:
@@ -139,6 +143,8 @@ 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,
) -> ResolvedModelConfig:
_check_role(role)
if registry is None:
@@ -186,7 +192,12 @@ class ModelRegistryResolver:
# Save-time contract checks re-applied at resolve time: auth mode,
# credential reference, declared capabilities, and parameters.
parameters = resolve_parameters(
provider, model, spec, reasoning_effort_override=reasoning_effort_override
provider,
model,
spec,
reasoning_effort_override=reasoning_effort_override,
temperature_override=temperature_override,
top_p_override=top_p_override,
)
auth_spec = spec.auth_specs[provider.auth.mode]
+17 -3
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}`` selection (inherit
pre-resolution ``{primary, reasoning_effort, temperature, top_p}`` 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, NonNegativeInt, PositiveInt
from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt
from .adapters import find_adapter_spec
from .errors import (
@@ -76,6 +76,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)
class SnapshotPayload(BaseModel):
@@ -126,6 +131,8 @@ class SnapshotCreation(BaseModel):
def compute_selection_hash(
primary: ModelRef | None,
reasoning_effort: ReasoningEffort | None = None,
temperature: float | None = None,
top_p: float | None = None,
) -> str:
"""Hash the pre-resolution selection; inherit participates as ``null``."""
encoded = json.dumps(
@@ -136,6 +143,8 @@ 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,
},
sort_keys=True,
separators=(",", ":"),
@@ -198,7 +207,10 @@ class SnapshotService:
"primary model first.",
)
selection_hash = compute_selection_hash(
request.primary, request.reasoning_effort
request.primary,
request.reasoning_effort,
request.temperature,
request.top_p,
)
existing = self._store.find_active_run_snapshot(
deployment_id=request.deployment_id,
@@ -231,6 +243,8 @@ class SnapshotService:
"primary",
registry=registry,
reasoning_effort_override=request.reasoning_effort,
temperature_override=request.temperature,
top_p_override=request.top_p,
)
payload = {
"registry_revision": registry.revision,
+64 -4
View File
@@ -191,11 +191,12 @@ def _request(**overrides):
return SnapshotCreateRequest.model_validate(payload)
# SHA-256 of the canonical JSON '{"primary":null,"reasoning_effort":null}' —
# the inherit selection with no effort override. Pinned as a literal to lock
# the wire contract.
# SHA-256 of the canonical JSON
# '{"primary":null,"reasoning_effort":null,"temperature":null,"top_p":null}' —
# the inherit selection with no overrides. Pinned as a literal to lock the
# wire contract.
INHERIT_SELECTION_HASH = (
"03e47e7f41396b231bd558734715697a5aebfa114ccfd861a0f7e824e32dac8d"
"0e7a49530963e46f999f6ee07e91f3e9b91fca50dfd126b8cc8aebd64055d6cc"
)
@@ -275,6 +276,65 @@ 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):
# 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).
store = active_store
registry = store.load_registry()
payload = registry.model_dump(mode="json")
payload["providers"][0]["models"].append(
{
"key": "glm-air",
"name": "GLM Air",
"upstream_model_id": "glm-air",
"enabled": False,
"runtime": _model_runtime(),
}
)
registry = store.save_registry(
expected_revision=registry.revision,
registry=RegistryV4.model_validate(payload),
)
_verify(store, registry, "zhipu-glm", "glm-air")
payload = registry.model_dump(mode="json")
payload["providers"][0]["models"][1]["enabled"] = True
registry = store.save_registry(
expected_revision=registry.revision,
registry=RegistryV4.model_validate(payload),
)
service = SnapshotService(store, ModelRegistryResolver(store))
creation = service.create(
_request(
primary={"provider_id": "zhipu-glm", "model_key": "glm-air"},
temperature=1.1,
top_p=0.5,
)
)
options = creation.snapshot.payload.primary.request_options
assert options.temperature == 1.1
assert options.top_p == 0.5
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))
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
def test_temperature_top_p_overrides_change_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
)
# 未覆盖(None)与缺省一致:不传覆盖时 hash 与旧语义相同。
assert compute_selection_hash(None, None, None, None) == (
plain.snapshot.selection_hash
)
def test_bootstrap_registry_is_not_ready(self, store):
service = SnapshotService(store, ModelRegistryResolver(store))
with pytest.raises(ModelRegistryError) as excinfo: