docs(registry): implementation plan for mutually-exclusive sampling_override
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,793 @@
|
||||
# sampling_override 互斥覆盖 实现计划
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 把会话级 temperature/top_p 平铺覆盖重写为判别联合 `sampling_override`(默认/temperature/top_p 三选一),覆盖其一时另一参数连同注册表默认从请求中省略。
|
||||
|
||||
**Architecture:** 全链路统一 `sampling_override: { kind: "temperature" | "top_p", value: number } | null`:线程元数据 `model_selection` → CAS PATCH 校验 → 快照 `selection_hash` → resolver 单参数 override(另一参数 OMIT)→ adapter 参数契约校验 → `request_options`;聊天栏两个常显滑块换成"默认 | T | P"分段选择器 + 单滑块。设计文档:`docs/superpowers/specs/2026-07-30-sampling-override-design.md`。
|
||||
|
||||
**Tech Stack:** Python(pydantic / 自研 model_registry), Next.js WebUI(TypeScript / vitest)。
|
||||
|
||||
**两个仓库:**
|
||||
- 后端:`/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist`(下称 BACKEND)
|
||||
- 前端:`/Users/m4/Projects/EvoSci/OriginEvoScientist/EvoScientist-WebUI`(下称 WEBUI)
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- 不留兼容 shim:平铺 `temperature`/`top_p` 覆盖字段、`temperature_override`/`top_p_override` kwargs、旧 hash 签名全部移除。唯一兼容行为:BFF parse 与 useChat 镜像**容忍并丢弃**旧元数据里的平铺 `temperature`/`top_p` 键(归一化为 null,不报错)。
|
||||
- 值域:temperature ∈ [0,2];top_p ∈ (0,1];NaN/Inf 拒绝。模型级契约(如 Anthropic temperature ≤ 1)只在后端 adapter `ParameterRule` 校验,BFF/前端不硬编码。
|
||||
- `null` = 未覆盖,使用注册表(model → provider 默认)继承值,两者可能同时发送(注册表配置者责任,维持现状)。
|
||||
- 覆盖仅作用于 primary 角色;`inherit` 选择下不允许携带覆盖。
|
||||
- 被省略的参数走 `_resolve_nullable_parameter(name, rule, None)`:正常得 OMIT;若契约声明 `nullable=="forbidden"` 则抛错(快照创建 4xx)——这是有意的严格性。
|
||||
- 后端测试:`cd BACKEND && .venv/bin/python -m pytest <files> -v`;前端测试:`cd WEBUI && npx vitest run <file>`。
|
||||
- openapi.json 由 `scripts/export_model_registry_schema.py` 生成,schema 变动后必须重新生成(BACKEND 有同步测试)。
|
||||
- `setModelSelection(next)` 是**整体替换**语义,所有调用点必须 spread 现有 selection,否则会丢字段。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 后端 — SamplingOverride + resolver OMIT 语义 + 快照 hash
|
||||
|
||||
**Files:**
|
||||
- Modify: `BACKEND/EvoScientist/model_registry/schemas.py`(新增 `SamplingOverride`)
|
||||
- Modify: `BACKEND/EvoScientist/model_registry/adapters.py:580-599`(签名与 docstring)、`648-662`(temperature/top_p 解析段)
|
||||
- Modify: `BACKEND/EvoScientist/model_registry/resolver.py:100-119,137-147,194-201`(kwargs 透传)
|
||||
- Modify: `BACKEND/EvoScientist/model_registry/snapshots.py:13-14,66-83,131-152,209-214,241-248`(docstring、请求模型、hash、create)
|
||||
- Modify: `BACKEND/EvoScientist/model_registry/openapi.json`(脚本重新生成)
|
||||
- Test: `BACKEND/tests/test_snapshots.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces:
|
||||
- `SamplingOverride(BaseModel)`:`kind: Literal["temperature","top_p"]`、`value: float`,validator 按 kind 校验值域并拒绝 NaN/Inf(定义于 schemas.py)。
|
||||
- `resolve_parameters(provider, model, spec, *, reasoning_effort_override=None, sampling_override: SamplingOverride | None = None)`
|
||||
- `ModelRegistryResolver.resolve(..., sampling_override=None)`(`_resolve` 同步)
|
||||
- `SnapshotCreateRequest.sampling_override: SamplingOverride | None = None`(替换旧平铺字段)
|
||||
- `compute_selection_hash(primary, reasoning_effort=None, sampling_override=None)`;override 序列化为 `{"kind":...,"value":...}` 或 `null`
|
||||
- Consumes: 无(计划起点)。
|
||||
|
||||
- [ ] **Step 1: 改写失败测试**
|
||||
|
||||
`BACKEND/tests/test_snapshots.py` 做四处修改:
|
||||
|
||||
(1) 文件顶部固定哈希常量(约 194-200 行)替换为新线协议值:
|
||||
|
||||
```python
|
||||
# SHA-256 of the canonical JSON
|
||||
# '{"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 = (
|
||||
"417399175386cea86c726c1bd2b833bd951066eae2e8f419b99076e9c91724a6"
|
||||
)
|
||||
```
|
||||
|
||||
(2) 导入处补上 `SamplingOverride`(与 `compute_selection_hash` 同一个 import 块;若包 `__init__` 未导出,用 `from EvoScientist.model_registry.schemas import SamplingOverride`)和 pydantic 的 `ValidationError`。
|
||||
|
||||
(3) `test_temperature_top_p_overrides_freeze_into_primary`(279-316 行)整体替换为:
|
||||
|
||||
```python
|
||||
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).
|
||||
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))
|
||||
ref = {"provider_id": "zhipu-glm", "model_key": "glm-air"}
|
||||
|
||||
# temperature override: top_p (provider default 0.95) is omitted.
|
||||
creation = service.create(
|
||||
_request(
|
||||
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)
|
||||
```
|
||||
|
||||
(4) `test_overrides_rejected_outside_model_contract`(318-323 行)与 `test_temperature_top_p_overrides_change_selection_hash`(325-336 行)替换为:
|
||||
|
||||
```python
|
||||
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(sampling_override={"kind": "temperature", "value": 1.5})
|
||||
)
|
||||
assert excinfo.value.code == UNSUPPORTED_RUNTIME_PARAMETER
|
||||
|
||||
def test_sampling_override_changes_selection_hash(self, service):
|
||||
plain = service.create(_request())
|
||||
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) == (
|
||||
plain.snapshot.selection_hash
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行测试确认失败**
|
||||
|
||||
```bash
|
||||
cd BACKEND && .venv/bin/python -m pytest tests/test_snapshots.py -v -k "sampling or selection_hash or inherit"
|
||||
```
|
||||
|
||||
预期:FAIL(`SnapshotCreateRequest` 无 `sampling_override` 字段;`compute_selection_hash` 旧签名;hash 常量不匹配)。
|
||||
|
||||
- [ ] **Step 3: schemas.py 新增 SamplingOverride**
|
||||
|
||||
`BACKEND/EvoScientist/model_registry/schemas.py`:顶部加 `import math`;在 `ReasoningEffort` 定义(42 行)之后插入:
|
||||
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
- [ ] **Step 4: adapters.py resolve_parameters 重写**
|
||||
|
||||
签名(580-588 行)把 `temperature_override`/`top_p_override` 两行替换为:
|
||||
|
||||
```python
|
||||
sampling_override: SamplingOverride | None = None,
|
||||
```
|
||||
|
||||
docstring 末段("The three overrides ...")替换为:
|
||||
|
||||
```
|
||||
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.
|
||||
```
|
||||
|
||||
temperature/top_p 解析段(648-662 行)替换为:
|
||||
|
||||
```python
|
||||
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 = 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)
|
||||
```
|
||||
|
||||
`.schemas` import 块(39 行起)加 `SamplingOverride`。
|
||||
|
||||
- [ ] **Step 5: resolver.py 透传**
|
||||
|
||||
`resolve`(100-119 行)与 `_resolve`(137-147 行)签名中 `temperature_override`/`top_p_override` 两行替换为 `sampling_override: SamplingOverride | None = None`,;调用处相应改为 `sampling_override=sampling_override`(`resolve` 内 117-118、`_resolve` 内 194-201 的 `resolve_parameters` 调用)。`SamplingOverride` 加入 `.schemas` import。
|
||||
|
||||
- [ ] **Step 6: snapshots.py 请求模型、hash 与 create**
|
||||
|
||||
(1) `SnapshotCreateRequest`(79-83 行)两个平铺字段替换为:
|
||||
|
||||
```python
|
||||
# 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
|
||||
```
|
||||
|
||||
pydantic import 行(37)移除不再使用的 `Field`;`.schemas` import 加 `SamplingOverride`。
|
||||
|
||||
(2) `compute_selection_hash`(131-152 行):
|
||||
|
||||
```python
|
||||
def compute_selection_hash(
|
||||
primary: ModelRef | None,
|
||||
reasoning_effort: ReasoningEffort | None = None,
|
||||
sampling_override: SamplingOverride | None = None,
|
||||
) -> str:
|
||||
"""Hash the pre-resolution selection; inherit participates as ``null``."""
|
||||
encoded = json.dumps(
|
||||
{
|
||||
"primary": (
|
||||
None
|
||||
if primary is None
|
||||
else {"provider_id": primary.provider_id, "model_key": primary.model_key}
|
||||
),
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"sampling_override": (
|
||||
None
|
||||
if sampling_override is None
|
||||
else {"kind": sampling_override.kind, "value": sampling_override.value}
|
||||
),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
|
||||
```
|
||||
|
||||
(3) `create` 中 hash 调用(209-214)改为:
|
||||
|
||||
```python
|
||||
selection_hash = compute_selection_hash(
|
||||
request.primary,
|
||||
request.reasoning_effort,
|
||||
request.sampling_override,
|
||||
)
|
||||
```
|
||||
|
||||
resolver 调用(241-248)中两个旧 kwargs 行替换为:
|
||||
|
||||
```python
|
||||
sampling_override=request.sampling_override,
|
||||
```
|
||||
|
||||
(4) 模块 docstring(13-14 行)`{primary, reasoning_effort, temperature, top_p}` 改为 `{primary, reasoning_effort, sampling_override}`。
|
||||
|
||||
- [ ] **Step 7: 重新生成 openapi.json**
|
||||
|
||||
```bash
|
||||
cd BACKEND && .venv/bin/python scripts/export_model_registry_schema.py
|
||||
```
|
||||
|
||||
预期输出 `Wrote .../openapi.json`。
|
||||
|
||||
- [ ] **Step 8: 运行后端测试**
|
||||
|
||||
```bash
|
||||
cd BACKEND && .venv/bin/python -m pytest tests/test_snapshots.py tests/test_resolver.py tests/test_adapter_contracts.py tests/test_model_registry_http.py tests/test_snapshot_runtime.py -q
|
||||
```
|
||||
|
||||
预期:全部 PASS(含继承时 `public_snapshot_view` 仍显示 temperature 0.7/top_p 0.95 的旧用例)。
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
cd BACKEND && git add EvoScientist/model_registry/schemas.py EvoScientist/model_registry/adapters.py EvoScientist/model_registry/resolver.py EvoScientist/model_registry/snapshots.py EvoScientist/model_registry/openapi.json tests/test_snapshots.py
|
||||
git commit -m "feat(registry): mutually-exclusive sampling_override frozen into run snapshots"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: WebUI — 类型 + BFF 校验 + 客户端镜像 + runs 路由
|
||||
|
||||
**Files:**
|
||||
- Modify: `WEBUI/src/lib/modelRegistry.ts:197-207`(`ThreadModelSelection`,新增 `SamplingOverride`)
|
||||
- Modify: `WEBUI/src/lib/server/threadModelSelection.ts:32-81`(parse)
|
||||
- Modify: `WEBUI/src/app/hooks/useChat.ts:176-206`(`readSelectionFromMetadata` 镜像)
|
||||
- Modify: `WEBUI/src/app/api/conversations/[threadId]/runs/route.ts:220-239`(快照 POST body)
|
||||
- Test: `WEBUI/src/lib/server/threadModelSelection.test.ts`、`WEBUI/src/app/api/conversations/[threadId]/runs/route.test.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 的 `SnapshotCreateRequest.sampling_override`。
|
||||
- Produces: `SamplingOverride = { kind: "temperature"; value: number } | { kind: "top_p"; value: number }`;`ThreadModelSelection = "inherit" | { primary: ModelRef; reasoning_effort?: ReasoningEffort | null; sampling_override?: SamplingOverride | null }`。Task 3 消费。
|
||||
|
||||
- [ ] **Step 1: 改写失败测试**
|
||||
|
||||
`threadModelSelection.test.ts`:
|
||||
|
||||
(1) 四个旧期望对象里的 `temperature: null, top_p: null` 全部改为 `sampling_override: null`("keeps an explicit reasoning effort"、"normalizes auto and missing effort to null" 内 2 处、"tolerates and drops a legacy auxiliary key")。
|
||||
|
||||
(2) "keeps explicit temperature/top_p overrides"、"normalizes missing generation overrides to null"、"rejects out-of-range or non-finite generation overrides" 三个用例替换为:
|
||||
|
||||
```typescript
|
||||
it("keeps an explicit sampling override", () => {
|
||||
expect(
|
||||
parseThreadModelSelection({
|
||||
primary: REF,
|
||||
sampling_override: { kind: "temperature", value: 1.2 },
|
||||
})
|
||||
).toEqual({
|
||||
primary: REF,
|
||||
reasoning_effort: null,
|
||||
sampling_override: { kind: "temperature", value: 1.2 },
|
||||
});
|
||||
expect(
|
||||
parseThreadModelSelection({
|
||||
primary: REF,
|
||||
sampling_override: { kind: "top_p", value: 0.5 },
|
||||
})
|
||||
).toEqual({
|
||||
primary: REF,
|
||||
reasoning_effort: null,
|
||||
sampling_override: { kind: "top_p", value: 0.5 },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes a missing sampling override to null", () => {
|
||||
expect(parseThreadModelSelection({ primary: REF })).toEqual({
|
||||
primary: REF,
|
||||
reasoning_effort: null,
|
||||
sampling_override: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid sampling override", () => {
|
||||
for (const bad of [
|
||||
{ primary: REF, sampling_override: { kind: "temperature", value: -0.1 } },
|
||||
{ primary: REF, sampling_override: { kind: "temperature", value: 2.1 } },
|
||||
{ primary: REF, sampling_override: { kind: "temperature", value: Number.NaN } },
|
||||
{ primary: REF, sampling_override: { kind: "top_p", value: 0 } },
|
||||
{ primary: REF, sampling_override: { kind: "top_p", value: 1.01 } },
|
||||
{ primary: REF, sampling_override: { kind: "top_k", value: 0.5 } },
|
||||
{ primary: REF, sampling_override: { kind: "temperature" } },
|
||||
{ primary: REF, sampling_override: "high" },
|
||||
]) {
|
||||
expect(() => parseThreadModelSelection(bad)).toThrowError(
|
||||
/model_selection must be/
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("tolerates and drops legacy flat temperature/top_p keys", () => {
|
||||
expect(
|
||||
parseThreadModelSelection({ primary: REF, temperature: 1.2, top_p: 0.5 })
|
||||
).toEqual({
|
||||
primary: REF,
|
||||
reasoning_effort: null,
|
||||
sampling_override: null,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
`route.test.ts` 的 "forwards generation overrides into the run snapshot request"(346-393 行):metadata 里 `temperature: 1.2, top_p: 0.5` 两行改为 `sampling_override: { kind: "temperature", value: 1.2 }`;`toMatchObject` 里同样两行改为 `sampling_override: { kind: "temperature", value: 1.2 }`。
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
```bash
|
||||
cd WEBUI && npx vitest run src/lib/server/threadModelSelection.test.ts "src/app/api/conversations/[threadId]/runs/route.test.ts"
|
||||
```
|
||||
|
||||
预期:FAIL(parse 丢弃/拒绝新字段;路由 body 无 `sampling_override`)。
|
||||
|
||||
- [ ] **Step 3: 实现类型与 parse**
|
||||
|
||||
`modelRegistry.ts:197-207` 替换为:
|
||||
|
||||
```typescript
|
||||
/** Mutually-exclusive per-thread sampling override (2026-07-30 design):
|
||||
* temperature and top_p must not be sent together, so overriding one
|
||||
* omits the other from the request. */
|
||||
export type SamplingOverride =
|
||||
| { kind: "temperature"; value: number }
|
||||
| { kind: "top_p"; value: number };
|
||||
|
||||
/** ThreadModelSelection (design doc 7.2). The optional reasoning_effort and
|
||||
* sampling_override are per-thread overrides frozen into the run snapshot
|
||||
* for the primary role; null means the registry default. */
|
||||
export type ThreadModelSelection =
|
||||
| "inherit"
|
||||
| {
|
||||
primary: ModelRef;
|
||||
reasoning_effort?: ReasoningEffort | null;
|
||||
sampling_override?: SamplingOverride | null;
|
||||
};
|
||||
```
|
||||
|
||||
`threadModelSelection.ts`:`isTemperature`/`isTopP` 保留,新增 kind 判别;`parseThreadModelSelection` 改写:
|
||||
|
||||
```typescript
|
||||
function isSamplingOverride(value: unknown): value is SamplingOverride {
|
||||
if (!isPlainObject(value)) return false;
|
||||
if (value.kind === "temperature") return isTemperature(value.value);
|
||||
if (value.kind === "top_p") return isTopP(value.value);
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Validates a browser-supplied `model_selection` payload (design doc 7.2).
|
||||
* `reasoning_effort` is optional; "auto" is normalized away so toggling the
|
||||
* picker back to Auto keeps the selection (and its snapshot hash) stable.
|
||||
* `sampling_override` is an optional mutually-exclusive per-thread override,
|
||||
* shape-checked here (temperature 0–2 / top_p (0,1]); model-level contracts
|
||||
* are enforced server-side when the run snapshot is created. Legacy
|
||||
* `auxiliary` and flat `temperature`/`top_p` keys are tolerated and
|
||||
* dropped. */
|
||||
export function parseThreadModelSelection(value: unknown): ThreadModelSelection {
|
||||
if (value === "inherit") return "inherit";
|
||||
if (
|
||||
isPlainObject(value) &&
|
||||
isModelRef(value.primary) &&
|
||||
(value.reasoning_effort === undefined ||
|
||||
value.reasoning_effort === null ||
|
||||
(typeof value.reasoning_effort === "string" &&
|
||||
EFFORTS.has(value.reasoning_effort as ReasoningEffort))) &&
|
||||
(value.sampling_override === undefined ||
|
||||
value.sampling_override === null ||
|
||||
isSamplingOverride(value.sampling_override))
|
||||
) {
|
||||
const effort = value.reasoning_effort;
|
||||
const override = value.sampling_override;
|
||||
return {
|
||||
primary: { provider_id: value.primary.provider_id, model_key: value.primary.model_key },
|
||||
reasoning_effort:
|
||||
typeof effort === "string" && effort !== "auto"
|
||||
? (effort as ReasoningEffort)
|
||||
: null,
|
||||
sampling_override: isSamplingOverride(override)
|
||||
? { kind: override.kind, value: override.value }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
throw new ThreadModelSelectionError(
|
||||
"INVALID_REQUEST",
|
||||
"model_selection must be \"inherit\" or { primary: ModelRef, reasoning_effort?: \"low\" | \"medium\" | \"high\" | null, sampling_override?: { kind: \"temperature\" | \"top_p\", value: number } | null }."
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
(`SamplingOverride` 加入顶部 `@/lib/modelRegistry` 的 type import。)
|
||||
|
||||
- [ ] **Step 4: useChat 客户端镜像**
|
||||
|
||||
`useChat.ts:176-206` 的 candidate 解析改为:
|
||||
|
||||
```typescript
|
||||
if (raw && typeof raw === "object") {
|
||||
// Legacy `auxiliary` and flat `temperature`/`top_p` keys are tolerated
|
||||
// and ignored.
|
||||
const candidate = raw as {
|
||||
primary?: unknown;
|
||||
reasoning_effort?: unknown;
|
||||
sampling_override?: unknown;
|
||||
};
|
||||
if (isModelRefShape(candidate.primary)) {
|
||||
const effort = candidate.reasoning_effort;
|
||||
const override = candidate.sampling_override as
|
||||
| { kind?: unknown; value?: unknown }
|
||||
| null
|
||||
| undefined;
|
||||
return {
|
||||
selection: {
|
||||
primary: candidate.primary,
|
||||
reasoning_effort:
|
||||
effort === "low" || effort === "medium" || effort === "high"
|
||||
? effort
|
||||
: null,
|
||||
sampling_override:
|
||||
override &&
|
||||
(override.kind === "temperature" || override.kind === "top_p") &&
|
||||
typeof override.value === "number" &&
|
||||
Number.isFinite(override.value)
|
||||
? { kind: override.kind, value: override.value }
|
||||
: null,
|
||||
},
|
||||
revision,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 5: runs 路由快照 body**
|
||||
|
||||
`route.ts:231-238` 的 `temperature`/`top_p` 两段替换为:
|
||||
|
||||
```typescript
|
||||
sampling_override:
|
||||
selectionRef === "inherit"
|
||||
? null
|
||||
: (selectionRef.sampling_override ?? null),
|
||||
```
|
||||
|
||||
- [ ] **Step 6: 运行测试 + 类型检查**
|
||||
|
||||
```bash
|
||||
cd WEBUI && npx vitest run src/lib/server/threadModelSelection.test.ts "src/app/api/conversations/[threadId]/runs/route.test.ts" && npx tsc --noEmit
|
||||
```
|
||||
|
||||
预期:测试 PASS;tsc 此时会报 ChatInterface.tsx 引用已删除的 `temperature`/`top_p` 字段——这些在 Task 3 修复,本任务只要求两个测试文件 PASS 且新增代码自身类型正确。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
cd WEBUI && git add src/lib/modelRegistry.ts src/lib/server/threadModelSelection.ts src/lib/server/threadModelSelection.test.ts src/app/hooks/useChat.ts "src/app/api/conversations/[threadId]/runs/route.ts" "src/app/api/conversations/[threadId]/runs/route.test.ts"
|
||||
git commit -m "feat(webui): mutually-exclusive sampling_override in thread model selection"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: WebUI — 聊天栏"默认 | T | P"分段选择器
|
||||
|
||||
**Files:**
|
||||
- Modify: `WEBUI/src/app/components/ChatInterface.tsx:2277-2306`(ReasoningEffortSlider 调用修 spread)、`2307-2376`(两个旧滑块挂载替换)、`2679-2796` 附近(`GenerationParamSlider` 之后新增 `SamplingOverridePicker`)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 2 的 `modelSelection`(含 `sampling_override`)、`setModelSelection`;`selectedCatalogEntry.default_temperature/default_top_p`(已存在,`selectableModels.find` memo,不动)。
|
||||
- Produces: 无新导出(页面内组件)。
|
||||
|
||||
项目无组件测试设施(无 testing-library/jsdom),本任务以 tsc + 全量 vitest + 浏览器手测验证。
|
||||
|
||||
- [ ] **Step 1: 修 ReasoningEffortSlider 的整体替换隐患**
|
||||
|
||||
`setModelSelection` 是整体替换;现有调用(2288-2291)只传 `primary` + `reasoning_effort`,会丢掉 `sampling_override`。改为:
|
||||
|
||||
```tsx
|
||||
await setModelSelection({
|
||||
...modelSelection,
|
||||
reasoning_effort: effort,
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新增 SamplingOverridePicker 组件**
|
||||
|
||||
在 `GenerationParamSlider` 定义之后追加:
|
||||
|
||||
```tsx
|
||||
function SamplingOverridePicker({
|
||||
disabled,
|
||||
override,
|
||||
fallbackTemperature,
|
||||
fallbackTopP,
|
||||
onCommit,
|
||||
}: {
|
||||
disabled: boolean;
|
||||
/** Current per-thread override; null means the registry defaults apply. */
|
||||
override: SamplingOverride | null;
|
||||
/** Registry-effective defaults used to anchor each slider. */
|
||||
fallbackTemperature: number | null;
|
||||
fallbackTopP: number | null;
|
||||
onCommit: (value: SamplingOverride | null) => void | Promise<void>;
|
||||
}) {
|
||||
const mode: "default" | "temperature" | "top_p" = override?.kind ?? "default";
|
||||
|
||||
const select = (next: "default" | "temperature" | "top_p") => {
|
||||
if (next === mode) return;
|
||||
if (next === "default") {
|
||||
void onCommit(null);
|
||||
return;
|
||||
}
|
||||
const value =
|
||||
next === "temperature"
|
||||
? (override?.kind === "temperature" ? override.value : null) ??
|
||||
fallbackTemperature ??
|
||||
1
|
||||
: (override?.kind === "top_p" ? override.value : null) ??
|
||||
fallbackTopP ??
|
||||
1;
|
||||
void onCommit({ kind: next, value });
|
||||
};
|
||||
|
||||
const modes = [
|
||||
{ key: "default", label: "默认" },
|
||||
{ key: "temperature", label: "T" },
|
||||
{ key: "top_p", label: "P" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("flex items-center gap-1.5", disabled && "opacity-50")}
|
||||
title={
|
||||
disabled
|
||||
? "Pick a model to override the registry-configured sampling"
|
||||
: "Sampling override: temperature and top_p are mutually exclusive"
|
||||
}
|
||||
>
|
||||
{/* ≥sm: segmented control */}
|
||||
<span className="hidden sm:flex items-center overflow-hidden rounded border border-border">
|
||||
{modes.map(({ key, label }) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={mode === key}
|
||||
onClick={() => select(key)}
|
||||
className={cn(
|
||||
"px-1.5 py-0.5 text-xs transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed",
|
||||
mode === key
|
||||
? "bg-accent font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-accent/60"
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</span>
|
||||
{/* <sm: dropdown */}
|
||||
<select
|
||||
aria-label="Sampling override"
|
||||
disabled={disabled}
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
select(event.target.value as "default" | "temperature" | "top_p")
|
||||
}
|
||||
className="sm:hidden rounded border border-border bg-transparent px-1 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
{modes.map(({ key, label }) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{mode === "temperature" && (
|
||||
<GenerationParamSlider
|
||||
ariaLabel="Temperature"
|
||||
disabled={disabled}
|
||||
override={override?.kind === "temperature" ? override.value : null}
|
||||
fallback={fallbackTemperature}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
onCommit={(value) =>
|
||||
void onCommit(value === null ? null : { kind: "temperature", value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{mode === "top_p" && (
|
||||
<GenerationParamSlider
|
||||
ariaLabel="Top P"
|
||||
disabled={disabled}
|
||||
override={override?.kind === "top_p" ? override.value : null}
|
||||
fallback={fallbackTopP}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.05}
|
||||
onCommit={(value) =>
|
||||
void onCommit(value === null ? null : { kind: "top_p", value })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
注意:`GenerationParamSlider` 自身带 `hidden sm:flex`,小屏只出下拉(选档即按锚点值提交),不出现滑块——与 spec 的小屏行为一致。组件顶部 import 加 `SamplingOverride` 类型(`@/lib/modelRegistry`)。
|
||||
|
||||
- [ ] **Step 3: 替换挂载点**
|
||||
|
||||
`ChatInterface.tsx:2307-2376` 两个 `{currentModel && (<GenerationParamSlider .../>)}` 块整体替换为:
|
||||
|
||||
```tsx
|
||||
{currentModel && (
|
||||
<SamplingOverridePicker
|
||||
disabled={modelSelection === "inherit"}
|
||||
override={
|
||||
modelSelection === "inherit"
|
||||
? null
|
||||
: (modelSelection.sampling_override ?? null)
|
||||
}
|
||||
fallbackTemperature={
|
||||
selectedCatalogEntry?.default_temperature ?? null
|
||||
}
|
||||
fallbackTopP={selectedCatalogEntry?.default_top_p ?? null}
|
||||
onCommit={async (value) => {
|
||||
if (modelSelection === "inherit") return;
|
||||
try {
|
||||
await setModelSelection({
|
||||
...modelSelection,
|
||||
sampling_override: value,
|
||||
});
|
||||
toast.success(
|
||||
value === null
|
||||
? "Sampling back to the registry default."
|
||||
: value.kind === "temperature"
|
||||
? `Temperature set to ${value.value.toFixed(2)} (top_p omitted).`
|
||||
: `Top P set to ${value.value.toFixed(2)} (temperature omitted).`
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
err instanceof Error
|
||||
? `Couldn't update sampling: ${err.message}`
|
||||
: "Couldn't update sampling — try again."
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 类型检查 + 全量测试**
|
||||
|
||||
```bash
|
||||
cd WEBUI && npx tsc --noEmit && npx vitest run
|
||||
```
|
||||
|
||||
预期:tsc 干净;全量 vitest PASS。
|
||||
|
||||
- [ ] **Step 5: 浏览器手测**
|
||||
|
||||
启动 WebUI(端口 4716)与 langgraph dev(6174,含 Task 1 后端代码,restart 生效;两个 service token 须对齐)。验证:
|
||||
1. 选一个模型 → 聊天栏出现"默认 | T | P"分段选择器,初始在"默认",无滑块。
|
||||
2. 点 T → toast "Temperature set to ...(top_p omitted)." → 滑块出现并停在提交值;刷新页面后仍在 T 档(元数据持久化)。
|
||||
3. 拖滑块改值 → toast 更新;点数值清空回车 → 回到"默认"档。
|
||||
4. 切到 P → temperature 覆盖被替换;发一条消息 → 快照 `request_options` 只带 top_p(查 `public_snapshot_view` 或日志)。
|
||||
5. 切回"默认" → 发消息 → 注册表两个默认都恢复发送。
|
||||
6. inherit(未选模型)时选择器禁用;小屏(<sm)只出下拉。
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
cd WEBUI && git add src/app/components/ChatInterface.tsx
|
||||
git commit -m "feat(webui): segmented default/temperature/top_p sampling picker in the chat bar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review 记录
|
||||
|
||||
- Spec 覆盖:§1 数据模型 → Task 2(类型/parse/镜像)+ Task 1(schemas);§2 resolver OMIT 语义 + hash → Task 1;§3 BFF/runs → Task 2;§4 UI 分段选择器(含小屏下拉)→ Task 3;§5 目录默认值 → 不动(已上线);§6 错误处理 → Task 1 契约拒绝 + Task 2 parse 400 + Task 3 toast;§7 测试 → 各 Task 内嵌;§8 移除项 → Task 1(后端平铺字段/kwargs/旧 hash)与 Task 2/3(WebUI 平铺字段/旧滑块)。
|
||||
- 占位符:无 TBD;所有代码块为完整可复制实现。
|
||||
- 类型一致性:`SamplingOverride`(Python/TS 同名同形)→ `SnapshotCreateRequest.sampling_override` → BFF body `sampling_override` → `ThreadModelSelection.sampling_override` → `compute_selection_hash(primary, reasoning_effort, sampling_override)`,全链路命名一致;hash 常量已按新线协议预计算(`{"primary":null,"reasoning_effort":null,"sampling_override":null}` 的 SHA-256)。
|
||||
- 整体替换陷阱:`setModelSelection` 为替换语义,Task 3 Step 1 顺带修复 ReasoningEffortSlider 丢字段的既有隐患。
|
||||
Reference in New Issue
Block a user