Add per-model generation settings to the model selector
Each text and vision harness setting now carries the generation settings its model takes (temperature, sampling limits, penalties, seed, reasoning effort), edited in a modal under the model pick so the harness block stays short. Nothing is set by default, so models keep their provider's own values until a super admin or admin changes them. A setting is only offered and only sent when the catalog says the model takes it: OpenRouter's supported_parameters is now stored on harness_models and refreshed by the hourly sync. Other providers list no parameters, so their models get them all.
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import type { HarnessParameter } from "@/src/dict/harness-parameter-dict";
|
||||
import type { HarnessParameterValue } from "@/src/dict/harness-parameter-dict";
|
||||
|
||||
export const DefaultParameterValue = "default";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
parameter: HarnessParameter;
|
||||
value?: HarnessParameterValue;
|
||||
onChange: (value: HarnessParameterValue | undefined) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One generation setting: a number field (empty for the provider's
|
||||
* default) or a pick from fixed choices ("Default" for the provider's).
|
||||
*/
|
||||
export default function HarnessParameterControl({
|
||||
id,
|
||||
parameter,
|
||||
value,
|
||||
onChange,
|
||||
}: Props) {
|
||||
if (parameter.value_type == "select") {
|
||||
return (
|
||||
<Select
|
||||
id={id}
|
||||
title={parameter.title}
|
||||
label={parameter.title}
|
||||
showLabel
|
||||
info={parameter.description}
|
||||
value={value ? String(value) : DefaultParameterValue}
|
||||
options={[
|
||||
{
|
||||
value: DefaultParameterValue,
|
||||
title: "Default (the model's own)",
|
||||
},
|
||||
...parameter.options.map((option) => ({
|
||||
value: option.value,
|
||||
title: option.title,
|
||||
})),
|
||||
]}
|
||||
changeHandler={(picked) =>
|
||||
onChange(
|
||||
picked == DefaultParameterValue ? undefined : picked,
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
id={id}
|
||||
title={parameter.title}
|
||||
label={parameter.title}
|
||||
showLabel
|
||||
info={parameter.description}
|
||||
numberText
|
||||
min={parameter.min}
|
||||
max={"max" in parameter ? parameter.max : undefined}
|
||||
step={parameter.step}
|
||||
defaultValue={value === undefined ? "" : String(value)}
|
||||
placeholder="Default"
|
||||
changeHandler={(next) => {
|
||||
if (next.trim() == "") {
|
||||
onChange(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const number = Number(next);
|
||||
|
||||
if (!Number.isNaN(number)) onChange(number);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
HarnessParameters,
|
||||
modelTakesParameter,
|
||||
} from "@/src/dict/harness-parameter-dict";
|
||||
import type { HarnessModelOption } from "@/src/types";
|
||||
import HarnessParameterControl from "./harness-parameter-control";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
/**
|
||||
* The model whose settings these are: only what it takes is shown.
|
||||
*/
|
||||
model?: HarnessModelOption;
|
||||
params?: Record<string, number | string>;
|
||||
onChange: (params: Record<string, number | string>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The generation settings the picked model takes: temperature, sampling
|
||||
* limits, penalties, seed and reasoning effort. A model whose provider
|
||||
* lists no parameters (anything but OpenRouter) gets them all.
|
||||
*/
|
||||
export default function HarnessParameterFields({
|
||||
id,
|
||||
model,
|
||||
params,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const shown = HarnessParameters.filter((parameter) =>
|
||||
model
|
||||
? modelTakesParameter({
|
||||
parameter,
|
||||
supported: model.supported_parameters,
|
||||
})
|
||||
: false,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{shown.map((parameter) => (
|
||||
<HarnessParameterControl
|
||||
key={parameter.key}
|
||||
id={`${id}-${parameter.key}`}
|
||||
parameter={parameter}
|
||||
value={params?.[parameter.key]}
|
||||
onChange={(value) => {
|
||||
const next = { ...params };
|
||||
|
||||
if (value === undefined) delete next[parameter.key];
|
||||
else next[parameter.key] = value;
|
||||
|
||||
onChange(next);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { HarnessParameters } from "@/src/dict/harness-parameter-dict";
|
||||
import type { HarnessModelOption } from "@/src/types";
|
||||
import HarnessParameterFields from "./harness-parameter-fields";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
/**
|
||||
* The model the settings are for: only what it takes is offered.
|
||||
*/
|
||||
model: HarnessModelOption;
|
||||
params?: Record<string, number | string>;
|
||||
onChange: (params: Record<string, number | string>) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The generation settings for the picked model (temperature, reasoning
|
||||
* effort, sampling limits), in a modal so they don't lengthen the harness
|
||||
* block. The target reads "Model settings" and names what is set.
|
||||
*/
|
||||
export default function HarnessParameterModal({
|
||||
id,
|
||||
model,
|
||||
params,
|
||||
onChange,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const set = HarnessParameters.flatMap((parameter) => {
|
||||
const value = params?.[parameter.key];
|
||||
|
||||
if (value === undefined || value === "") return [];
|
||||
|
||||
const label =
|
||||
parameter.value_type == "select"
|
||||
? parameter.options.find((option) => option.value == value)
|
||||
?.title || String(value)
|
||||
: String(value);
|
||||
|
||||
return [`${parameter.title} ${label}`];
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Stack className="gap-0.5! items-start cursor-pointer">
|
||||
<Span className="text-sm text-ink underline decoration-hairline-strong underline-offset-2">
|
||||
Model settings
|
||||
</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary">
|
||||
{set.length
|
||||
? set.join(" · ")
|
||||
: "Temperature, reasoning effort, sampling limits"}
|
||||
</Span>
|
||||
</Stack>
|
||||
}
|
||||
targetWrapperProps={{
|
||||
className:
|
||||
"self-start rounded-default-sm focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper",
|
||||
}}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch pb-2">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{model.name} settings
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
How this model answers. Empty leaves the provider's own
|
||||
default; a lower temperature keeps a bot literal and
|
||||
repeatable.
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack className="w-full gap-4! items-stretch">
|
||||
<HarnessParameterFields
|
||||
id={id}
|
||||
model={model}
|
||||
params={params}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import Checkbox from "@moduletrace/twui/form/Checkbox";
|
||||
import { HarnessParameterHarnesses } from "@/src/dict/harness-parameter-dict";
|
||||
import type { HarnessFormValue, HarnessOverview } from "@/src/types";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import HarnessCurrentModel from "./harness-current-model";
|
||||
import HarnessModelSelect from "./harness-model-select";
|
||||
import HarnessParameterModal from "./harness-parameter-modal";
|
||||
import HarnessProviderSelect from "./harness-provider-select";
|
||||
import HarnessVoicePreview from "./harness-voice-preview";
|
||||
import HarnessVoiceSelect from "./harness-voice-select";
|
||||
@@ -28,6 +30,15 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
|
||||
: harness.models;
|
||||
const picked = models.find((model) => model.id == value.model_id);
|
||||
const voices = (picked || harness.current)?.voices;
|
||||
/**
|
||||
* The model the settings are for: the pick, else the one it runs on now.
|
||||
*/
|
||||
const params_model = picked || harness.current;
|
||||
const shows_params =
|
||||
!value.inherit &&
|
||||
HarnessParameterHarnesses.includes(
|
||||
harness.harness as (typeof HarnessParameterHarnesses)[number],
|
||||
);
|
||||
const on_bot_page = harness.default !== undefined;
|
||||
const default_name = harness.default?.enabled === false
|
||||
? "turned off"
|
||||
@@ -79,6 +90,14 @@ export default function HarnessSettingGroup({ harness, value, onChange }: Props)
|
||||
changeHandler={(fallback) => onChange({ ...value, fallback })}
|
||||
/>
|
||||
) : null}
|
||||
{shows_params && params_model ? (
|
||||
<HarnessParameterModal
|
||||
id={id}
|
||||
model={params_model}
|
||||
params={value.params}
|
||||
onChange={(params) => onChange({ ...value, params })}
|
||||
/>
|
||||
) : null}
|
||||
{!value.inherit && harness.harness == "speech" && voices?.length ? (
|
||||
<HarnessVoiceSelect
|
||||
id={`${id}-voice`}
|
||||
|
||||
@@ -30,6 +30,22 @@ function isSame({ a, b }: { a: HarnessFormValue; b?: HarnessFormValue }) {
|
||||
(a.provider_id ?? null) == (b!.provider_id ?? null) &&
|
||||
a.fallback == b!.fallback &&
|
||||
a.enabled == b!.enabled &&
|
||||
(a.voice || "") == (b!.voice || "")
|
||||
(a.voice || "") == (b!.voice || "") &&
|
||||
paramsKey({ params: a.params }) == paramsKey({ params: b!.params })
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The generation settings as one comparable string.
|
||||
*/
|
||||
export function paramsKey({
|
||||
params,
|
||||
}: {
|
||||
params?: Record<string, number | string>;
|
||||
}) {
|
||||
const entries = Object.entries(params || {}).sort(([a], [b]) =>
|
||||
a.localeCompare(b),
|
||||
);
|
||||
|
||||
return JSON.stringify(entries);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { harnessParametersFromOptions } from "@/src/dict/harness-parameter-dict";
|
||||
import type {
|
||||
HarnessOverview,
|
||||
HarnessSettingsFormObject,
|
||||
@@ -26,6 +27,9 @@ export default function toHarnessForm({
|
||||
typeof harness.options.voice == "string"
|
||||
? harness.options.voice
|
||||
: undefined,
|
||||
params: harnessParametersFromOptions({
|
||||
options: harness.options,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user