feat(webui): accept temperature/top_p in thread model selection

This commit is contained in:
m4
2026-07-28 17:56:18 +08:00
parent 4cd3d2e9a4
commit 5557690e0e
4 changed files with 98 additions and 9 deletions
+12
View File
@@ -174,6 +174,8 @@ function readSelectionFromMetadata(metadata: Record<string, unknown> | undefined
const candidate = raw as {
primary?: unknown;
reasoning_effort?: unknown;
temperature?: unknown;
top_p?: unknown;
};
if (isModelRefShape(candidate.primary)) {
const effort = candidate.reasoning_effort;
@@ -184,6 +186,16 @@ function readSelectionFromMetadata(metadata: Record<string, unknown> | undefined
effort === "low" || effort === "medium" || effort === "high"
? effort
: null,
temperature:
typeof candidate.temperature === "number" &&
Number.isFinite(candidate.temperature)
? candidate.temperature
: null,
top_p:
typeof candidate.top_p === "number" &&
Number.isFinite(candidate.top_p)
? candidate.top_p
: null,
},
revision,
};
+5 -2
View File
@@ -192,13 +192,16 @@ export interface GetSelectableModelsResponse {
defaults?: RegistryDefaults;
}
/** ThreadModelSelection (design doc 7.2). The optional reasoning_effort is a
* per-thread override frozen into the run snapshot for the primary role. */
/** ThreadModelSelection (design doc 7.2). The optional reasoning_effort,
* temperature, and top_p 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;
temperature?: number | null;
top_p?: number | null;
};
export interface RegistryApiError {
+55 -3
View File
@@ -14,7 +14,12 @@ describe("parseThreadModelSelection", () => {
primary: REF,
reasoning_effort: "high",
})
).toEqual({ primary: REF, reasoning_effort: "high" });
).toEqual({
primary: REF,
reasoning_effort: "high",
temperature: null,
top_p: null,
});
});
it("normalizes auto and missing effort to null", () => {
@@ -23,10 +28,17 @@ describe("parseThreadModelSelection", () => {
primary: REF,
reasoning_effort: "auto",
})
).toEqual({ primary: REF, reasoning_effort: null });
).toEqual({
primary: REF,
reasoning_effort: null,
temperature: null,
top_p: null,
});
expect(parseThreadModelSelection({ primary: REF })).toEqual({
primary: REF,
reasoning_effort: null,
temperature: null,
top_p: null,
});
});
@@ -37,7 +49,47 @@ describe("parseThreadModelSelection", () => {
auxiliary: { provider_id: "ollama", model_key: "qwen3" },
reasoning_effort: "low",
})
).toEqual({ primary: REF, reasoning_effort: "low" });
).toEqual({
primary: REF,
reasoning_effort: "low",
temperature: null,
top_p: null,
});
});
it("keeps explicit temperature/top_p overrides", () => {
expect(
parseThreadModelSelection({ primary: REF, temperature: 1.2, top_p: 0.5 })
).toEqual({
primary: REF,
reasoning_effort: null,
temperature: 1.2,
top_p: 0.5,
});
});
it("normalizes missing generation overrides to null", () => {
expect(parseThreadModelSelection({ primary: REF })).toEqual({
primary: REF,
reasoning_effort: null,
temperature: null,
top_p: null,
});
});
it("rejects out-of-range or non-finite generation overrides", () => {
for (const bad of [
{ primary: REF, temperature: -0.1 },
{ primary: REF, temperature: 2.1 },
{ primary: REF, temperature: Number.NaN },
{ primary: REF, top_p: 0 },
{ primary: REF, top_p: 1.01 },
{ primary: REF, top_p: "high" },
]) {
expect(() => parseThreadModelSelection(bad)).toThrowError(
/model_selection must be/
);
}
});
it("rejects an unknown effort value", () => {
+26 -4
View File
@@ -29,10 +29,25 @@ function isModelRef(value: unknown): value is ModelRef {
);
}
function isTemperature(value: unknown): boolean {
return (
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 2
);
}
function isTopP(value: unknown): boolean {
return (
typeof value === "number" && Number.isFinite(value) && value > 0 && value <= 1
);
}
/** 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. A
* legacy `auxiliary` key from pre-6.1 metadata is tolerated and dropped. */
* picker back to Auto keeps the selection (and its snapshot hash) stable.
* `temperature`/`top_p` are optional per-thread overrides, shape-checked
* here (0–2 / (0,1]); model-level contracts are enforced server-side when
* the run snapshot is created. A legacy `auxiliary` key from pre-6.1
* metadata is tolerated and dropped. */
export function parseThreadModelSelection(value: unknown): ThreadModelSelection {
if (value === "inherit") return "inherit";
if (
@@ -41,7 +56,11 @@ export function parseThreadModelSelection(value: unknown): ThreadModelSelection
(value.reasoning_effort === undefined ||
value.reasoning_effort === null ||
(typeof value.reasoning_effort === "string" &&
EFFORTS.has(value.reasoning_effort as ReasoningEffort)))
EFFORTS.has(value.reasoning_effort as ReasoningEffort))) &&
(value.temperature === undefined ||
value.temperature === null ||
isTemperature(value.temperature)) &&
(value.top_p === undefined || value.top_p === null || isTopP(value.top_p))
) {
const effort = value.reasoning_effort;
return {
@@ -50,11 +69,14 @@ export function parseThreadModelSelection(value: unknown): ThreadModelSelection
typeof effort === "string" && effort !== "auto"
? (effort as ReasoningEffort)
: null,
temperature:
typeof value.temperature === "number" ? value.temperature : null,
top_p: typeof value.top_p === "number" ? value.top_p : null,
};
}
throw new ThreadModelSelectionError(
"INVALID_REQUEST",
"model_selection must be \"inherit\" or { primary: ModelRef, reasoning_effort?: \"low\" | \"medium\" | \"high\" | null }."
"model_selection must be \"inherit\" or { primary: ModelRef, reasoning_effort?: \"low\" | \"medium\" | \"high\" | null, temperature?: number | null, top_p?: number | null }."
);
}