diff --git a/src/app/hooks/useChat.ts b/src/app/hooks/useChat.ts index 3e5f2b1..3040dea 100644 --- a/src/app/hooks/useChat.ts +++ b/src/app/hooks/useChat.ts @@ -174,6 +174,8 @@ function readSelectionFromMetadata(metadata: Record | 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 | 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, }; diff --git a/src/lib/modelRegistry.ts b/src/lib/modelRegistry.ts index 1d547c3..f9079e3 100644 --- a/src/lib/modelRegistry.ts +++ b/src/lib/modelRegistry.ts @@ -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 { diff --git a/src/lib/server/threadModelSelection.test.ts b/src/lib/server/threadModelSelection.test.ts index 4c41662..f2d9336 100644 --- a/src/lib/server/threadModelSelection.test.ts +++ b/src/lib/server/threadModelSelection.test.ts @@ -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", () => { diff --git a/src/lib/server/threadModelSelection.ts b/src/lib/server/threadModelSelection.ts index 80cde3a..5c63ed3 100644 --- a/src/lib/server/threadModelSelection.ts +++ b/src/lib/server/threadModelSelection.ts @@ -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 }." ); }