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:
@@ -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)
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user