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,51 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
HarnessParameters,
|
||||
harnessParametersFromOptions,
|
||||
modelTakesParameter,
|
||||
withHarnessParameters,
|
||||
} from "./harness-parameter-dict";
|
||||
|
||||
const temperature = HarnessParameters.find((p) => p.key == "temperature")!;
|
||||
const reasoning = HarnessParameters.find((p) => p.key == "reasoning_effort")!;
|
||||
|
||||
describe("harness parameters", () => {
|
||||
test("reads only the known settings out of options", () => {
|
||||
expect(
|
||||
harnessParametersFromOptions({
|
||||
options: { voice: "alloy", temperature: 0.2, seed: 0, junk: "x" },
|
||||
}),
|
||||
).toEqual({ temperature: 0.2, seed: 0 });
|
||||
});
|
||||
|
||||
test("replaces the settings and keeps voice", () => {
|
||||
expect(
|
||||
withHarnessParameters({
|
||||
options: { voice: "alloy", temperature: 1, top_p: 0.5 },
|
||||
params: { temperature: 0.2 },
|
||||
}),
|
||||
).toEqual({ voice: "alloy", temperature: 0.2 });
|
||||
});
|
||||
|
||||
test("only offers what the model advertises", () => {
|
||||
const supported = ["tools", "temperature", "reasoning"];
|
||||
|
||||
expect(modelTakesParameter({ parameter: temperature, supported })).toBe(true);
|
||||
expect(modelTakesParameter({ parameter: reasoning, supported })).toBe(true);
|
||||
expect(
|
||||
modelTakesParameter({
|
||||
parameter: temperature,
|
||||
supported: ["tools", "top_p"],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("a model that lists no parameters takes them all", () => {
|
||||
expect(
|
||||
modelTakesParameter({ parameter: temperature, supported: [] }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
modelTakesParameter({ parameter: temperature, supported: undefined }),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* Generation settings a harness can ask its model for. Each is saved in the
|
||||
* setting's `options` (next to `voice`) and only sent to a model whose
|
||||
* catalog entry advertises it: a fallback model that rejects a field would
|
||||
* fail otherwise.
|
||||
*
|
||||
* - `key`: the option's key in `options`, and the field sent on the wire
|
||||
* - `advertised`: the name a provider's model list uses for it, when it
|
||||
* differs (OpenRouter's `supported_parameters`)
|
||||
* - `wire`: how the field is written, for providers that nest it
|
||||
*/
|
||||
export const HarnessParameters = [
|
||||
{
|
||||
key: "temperature",
|
||||
title: "Temperature",
|
||||
description: `Lower keeps a bot literal and repeatable, higher makes it more varied. Most models default to 1, which makes small bots invent details.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
key: "top_p",
|
||||
title: "Top P",
|
||||
description: `Nucleus sampling: how much of the model's probability mass it may pick from. Change this or temperature, rarely both.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
key: "top_k",
|
||||
title: "Top K",
|
||||
description: `How many of the likeliest tokens it may pick from. 0 turns it off. Not every provider takes it.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
max: 200,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "min_p",
|
||||
title: "Min P",
|
||||
description: `Drops tokens less likely than this share of the best token. Alternative to top_p.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "top_a",
|
||||
title: "Top A",
|
||||
description: `Drops tokens the model is only weakly considering. 0 turns it off.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
max: 1,
|
||||
step: 0.01,
|
||||
},
|
||||
{
|
||||
key: "frequency_penalty",
|
||||
title: "Frequency penalty",
|
||||
description: `Positive values make it repeat words less. Negative values make it repeat more.`,
|
||||
value_type: "number",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
},
|
||||
{
|
||||
key: "presence_penalty",
|
||||
title: "Presence penalty",
|
||||
description: `Positive values push it toward new topics, negative values back to what was already said.`,
|
||||
value_type: "number",
|
||||
min: -2,
|
||||
max: 2,
|
||||
step: 0.1,
|
||||
},
|
||||
{
|
||||
key: "repetition_penalty",
|
||||
title: "Repetition penalty",
|
||||
description: `1 leaves repetition alone, higher penalizes it. Some providers take this instead of the two penalties above.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
},
|
||||
{
|
||||
key: "seed",
|
||||
title: "Seed",
|
||||
description: `Asks for repeatable answers. Providers only do their best: the same seed usually gives the same answer, not always.`,
|
||||
value_type: "number",
|
||||
min: 0,
|
||||
step: 1,
|
||||
},
|
||||
{
|
||||
key: "reasoning_effort",
|
||||
title: "Reasoning effort",
|
||||
description: `How long a reasoning model thinks before it answers. "Default" leaves the provider's own setting alone. Models without reasoning ignore it.`,
|
||||
value_type: "select",
|
||||
advertised: "reasoning",
|
||||
wire: "reasoning",
|
||||
options: [
|
||||
{ value: "minimal", title: "Minimal" },
|
||||
{ value: "low", title: "Low" },
|
||||
{ value: "medium", title: "Medium" },
|
||||
{ value: "high", title: "High" },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
export type HarnessParameter = (typeof HarnessParameters)[number];
|
||||
export type HarnessParameterKey = HarnessParameter["key"];
|
||||
export type HarnessParameterValue = number | string;
|
||||
|
||||
/**
|
||||
* The harnesses whose models are chat models, so the only ones the
|
||||
* generation settings apply to.
|
||||
*/
|
||||
export const HarnessParameterHarnesses = ["text", "vision"] as const;
|
||||
|
||||
export const HarnessParameterKeys = HarnessParameters.map((parameter) => parameter.key);
|
||||
|
||||
/**
|
||||
* The generation settings a saved `options` object holds.
|
||||
*/
|
||||
export function harnessParametersFromOptions({
|
||||
options,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
}): Partial<Record<HarnessParameterKey, HarnessParameterValue>> {
|
||||
return Object.fromEntries(
|
||||
HarnessParameters.flatMap((parameter) => {
|
||||
const value = options[parameter.key];
|
||||
|
||||
return typeof value == "number" || (typeof value == "string" && value)
|
||||
? [[parameter.key, value]]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `options` object with the given generation settings replaced (and the
|
||||
* ones missing from it removed), keeping everything else (`voice`).
|
||||
*/
|
||||
export function withHarnessParameters({
|
||||
options,
|
||||
params,
|
||||
}: {
|
||||
options: Record<string, unknown>;
|
||||
params: Partial<Record<HarnessParameterKey, HarnessParameterValue>>;
|
||||
}): Record<string, unknown> {
|
||||
const rest: Record<string, unknown> = Object.fromEntries(
|
||||
Object.entries(options).filter(([key]) => !HarnessParameterKeys.includes(key as HarnessParameterKey)),
|
||||
);
|
||||
|
||||
return { ...rest, ...params };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a model advertises a setting. A model whose provider lists no
|
||||
* parameters (anything but OpenRouter) is taken to accept them all.
|
||||
*/
|
||||
export function modelTakesParameter({
|
||||
parameter,
|
||||
supported,
|
||||
}: {
|
||||
parameter: HarnessParameter;
|
||||
supported?: string[];
|
||||
}) {
|
||||
if (!supported?.length) return true;
|
||||
|
||||
return supported.includes(
|
||||
"advertised" in parameter ? parameter.advertised : parameter.key,
|
||||
);
|
||||
}
|
||||
@@ -54,6 +54,7 @@ export default async function grabHarnessModelEntries({
|
||||
name: `${model.name || model.slug}${row.available == 0 ? " (no longer listed)" : ""}`,
|
||||
free: model.free,
|
||||
voices: model.voices,
|
||||
supported_parameters: model.supported_parameters,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -23,6 +23,7 @@ export default function harnessModelRowToCatalog({
|
||||
context_length: numberOrUndefined(row.context_length),
|
||||
max_output_tokens: numberOrUndefined(row.max_output_tokens),
|
||||
voices: parseList({ text: row.voices }),
|
||||
supported_parameters: parseList({ text: row.supported_parameters }),
|
||||
intelligence_score: numberOrUndefined(row.intelligence_score),
|
||||
agentic_score: numberOrUndefined(row.agentic_score),
|
||||
released_at: numberOrUndefined(row.released_at),
|
||||
|
||||
@@ -21,6 +21,7 @@ const Columns = [
|
||||
"context_length",
|
||||
"max_output_tokens",
|
||||
"voices",
|
||||
"supported_parameters",
|
||||
"intelligence_score",
|
||||
"agentic_score",
|
||||
"released_at",
|
||||
@@ -69,6 +70,7 @@ export default async function upsertHarnessModels({
|
||||
model.context_length ?? null,
|
||||
model.max_output_tokens ?? null,
|
||||
model.voices ? JSON.stringify(model.voices) : null,
|
||||
model.supported_parameters ? JSON.stringify(model.supported_parameters) : null,
|
||||
model.intelligence_score ?? null,
|
||||
model.agentic_score ?? null,
|
||||
model.released_at ?? null,
|
||||
|
||||
@@ -3,6 +3,7 @@ import HarnessError from "../harness-error";
|
||||
import grabHarnessAdapter from "../providers/grab-harness-adapter";
|
||||
import resolveHarnessCandidates from "../resolve/resolve-harness-candidates";
|
||||
import withCandidates from "../resolve/with-candidates";
|
||||
import grabChatParams from "../text/grab-chat-params";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -35,6 +36,12 @@ export default async function readImage({ url, focus, signal, bot_id, workspace_
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
const saved_params = await grabChatParams({
|
||||
harness: "vision",
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
const { result } = await withCandidates({
|
||||
harness: "vision",
|
||||
candidates,
|
||||
@@ -57,7 +64,14 @@ export default async function readImage({ url, focus, signal, bot_id, workspace_
|
||||
],
|
||||
},
|
||||
],
|
||||
temperature: 0,
|
||||
/**
|
||||
* Reading is a lookup, so it stays literal unless a
|
||||
* temperature was set for the vision harness.
|
||||
*/
|
||||
params: {
|
||||
temperature: 0,
|
||||
...saved_params(candidate.model),
|
||||
},
|
||||
signal: signal || new AbortController().signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ const chat: HarnessAdapter["chat"] = async ({
|
||||
messages,
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
params,
|
||||
signal,
|
||||
}) => {
|
||||
const { provider, model } = candidate;
|
||||
@@ -32,7 +32,7 @@ const chat: HarnessAdapter["chat"] = async ({
|
||||
}
|
||||
: {}),
|
||||
...(max_tokens ? { max_tokens } : {}),
|
||||
...(temperature !== undefined ? { temperature } : {}),
|
||||
...(params || {}),
|
||||
},
|
||||
signal,
|
||||
candidate_key: candidate.key,
|
||||
|
||||
@@ -35,6 +35,7 @@ describe("openRouterModelToCatalog", () => {
|
||||
context_length: 1000000,
|
||||
max_output_tokens: 65536,
|
||||
voices: undefined,
|
||||
supported_parameters: ["tools", "temperature"],
|
||||
released_at: 1792000000000,
|
||||
expires_at: Date.parse("2026-12-01"),
|
||||
intelligence_score: 26.5,
|
||||
|
||||
@@ -56,6 +56,9 @@ export default function openRouterModelToCatalog({
|
||||
voices: model.supported_voices?.length
|
||||
? model.supported_voices
|
||||
: undefined,
|
||||
supported_parameters: model.supported_parameters?.length
|
||||
? model.supported_parameters
|
||||
: undefined,
|
||||
released_at: model.created ? model.created * 1000 : undefined,
|
||||
expires_at: Number.isNaN(expires_at) ? undefined : expires_at,
|
||||
intelligence_score: scores?.intelligence_index ?? undefined,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
HarnessMessage,
|
||||
HarnessToolDefinition,
|
||||
} from "../types";
|
||||
import grabChatParams from "./grab-chat-params";
|
||||
import prepareMessagesForModel from "./prepare-messages-for-model";
|
||||
import stripThinkTags from "./strip-think-tags";
|
||||
|
||||
@@ -23,7 +24,10 @@ export type GenerateTextParams = {
|
||||
*/
|
||||
prefer_key?: string;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
/**
|
||||
* Overrides the saved generation settings (media reading wants 0).
|
||||
*/
|
||||
params?: Record<string, number | string | { effort: string }>;
|
||||
signal?: AbortSignal;
|
||||
/**
|
||||
* Media readings shared across calls of one run.
|
||||
@@ -54,16 +58,22 @@ export default async function generateText({
|
||||
candidates,
|
||||
prefer_key,
|
||||
max_tokens,
|
||||
temperature,
|
||||
params,
|
||||
signal = new AbortController().signal,
|
||||
media_cache = new Map(),
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: GenerateTextParams): Promise<GenerateTextResult> {
|
||||
const resolved =
|
||||
candidates ||
|
||||
(await resolveHarnessCandidates({ harness: "text", bot_id, workspace_id }));
|
||||
const saved_params = params
|
||||
? undefined
|
||||
: await grabChatParams({ harness: "text", bot_id, workspace_id });
|
||||
|
||||
const { result, candidate } = await withCandidates({
|
||||
harness: "text",
|
||||
candidates:
|
||||
candidates || (await resolveHarnessCandidates({ harness: "text", bot_id, workspace_id })),
|
||||
candidates: resolved,
|
||||
prefer_key,
|
||||
run: async (candidate) => {
|
||||
const answer = await grabHarnessAdapter({
|
||||
@@ -80,7 +90,7 @@ export default async function generateText({
|
||||
}),
|
||||
tools,
|
||||
max_tokens,
|
||||
temperature,
|
||||
params: params || saved_params?.(candidate.model),
|
||||
signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
HarnessParameters,
|
||||
modelTakesParameter,
|
||||
} from "@/src/dict/harness-parameter-dict";
|
||||
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
|
||||
import type { HarnessModelInfo } from "../types";
|
||||
|
||||
type Params = {
|
||||
harness: "text" | "vision";
|
||||
bot_id?: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A model's generation settings for one call: what is saved in the
|
||||
* harness's setting (the workspace's, the bot's, else the default),
|
||||
* keyed by the field sent on the wire, keeping only what the model says it
|
||||
* takes. Empty when none are set, so the model gets its provider's
|
||||
* defaults.
|
||||
*/
|
||||
export default async function grabChatParams({
|
||||
harness,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<
|
||||
(model: HarnessModelInfo) => Record<string, number | string | { effort: string }>
|
||||
> {
|
||||
const setting = await grabHarnessSetting({
|
||||
harness,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
return (model) =>
|
||||
Object.fromEntries(
|
||||
HarnessParameters.flatMap((parameter) => {
|
||||
const value = setting.options[parameter.key];
|
||||
|
||||
if (typeof value != "number" && typeof value != "string") return [];
|
||||
if (!value && value !== 0) return [];
|
||||
if (
|
||||
!modelTakesParameter({
|
||||
parameter,
|
||||
supported: model.supported_parameters,
|
||||
})
|
||||
)
|
||||
return [];
|
||||
|
||||
const key = "wire" in parameter ? parameter.wire : parameter.key;
|
||||
|
||||
return [
|
||||
[key, key == "reasoning" ? { effort: String(value) } : value],
|
||||
];
|
||||
}),
|
||||
);
|
||||
}
|
||||
+11
-1
@@ -82,6 +82,12 @@ export type HarnessModelInfo = {
|
||||
context_length?: number;
|
||||
max_output_tokens?: number;
|
||||
voices?: string[];
|
||||
/**
|
||||
* The request fields the provider says it takes (OpenRouter's
|
||||
* `supported_parameters`). Undefined when the provider doesn't say:
|
||||
* then every generation setting is sent.
|
||||
*/
|
||||
supported_parameters?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -137,7 +143,11 @@ export type HarnessChatParams = {
|
||||
messages: HarnessMessage[];
|
||||
tools?: HarnessToolDefinition[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
/**
|
||||
* Sampling and reasoning fields, keyed by their wire name. The caller
|
||||
* only includes what the model advertises.
|
||||
*/
|
||||
params?: Record<string, number | string | { effort: string }>;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -4,6 +4,7 @@ import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-
|
||||
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
|
||||
import saveBotHarnessSetting from "@/src/functions/backend/db/harness/save-bot-harness-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { withHarnessParameters } from "@/src/dict/harness-parameter-dict";
|
||||
import { SaveBotHarnessSettingsRequestSchema } from "@/src/schemas/harness-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
@@ -71,7 +72,13 @@ export const handler: BunextAPIRouteHandler<
|
||||
provider_id: setting.provider_id,
|
||||
fallback: setting.fallback,
|
||||
enabled: setting.enabled,
|
||||
options: setting.voice ? { ...options, voice: setting.voice } : options,
|
||||
options: withHarnessParameters({
|
||||
options: {
|
||||
...options,
|
||||
...(setting.voice ? { voice: setting.voice } : {}),
|
||||
},
|
||||
params: setting.params || {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness
|
||||
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
|
||||
import saveHarnessSetting from "@/src/functions/backend/db/harness/save-harness-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { withHarnessParameters } from "@/src/dict/harness-parameter-dict";
|
||||
import { SaveHarnessSettingsRequestSchema } from "@/src/schemas/harness-schema";
|
||||
import type { HarnessOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
@@ -45,7 +46,13 @@ export const handler: BunextAPIRouteHandler<
|
||||
provider_id: setting.provider_id,
|
||||
fallback: setting.fallback,
|
||||
enabled: setting.enabled,
|
||||
options: setting.voice ? { ...options, voice: setting.voice } : options,
|
||||
options: withHarnessParameters({
|
||||
options: {
|
||||
...options,
|
||||
...(setting.voice ? { voice: setting.voice } : {}),
|
||||
},
|
||||
params: setting.params || {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-
|
||||
import saveWorkspaceHarnessSetting from "@/src/functions/backend/db/harness/save-workspace-harness-setting";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { withHarnessParameters } from "@/src/dict/harness-parameter-dict";
|
||||
import { SaveWorkspaceHarnessSettingsRequestSchema } from "@/src/schemas/harness-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
@@ -69,7 +70,13 @@ export const handler: BunextAPIRouteHandler<
|
||||
provider_id: setting.provider_id,
|
||||
fallback: setting.fallback,
|
||||
enabled: setting.enabled,
|
||||
options: setting.voice ? { ...options, voice: setting.voice } : options,
|
||||
options: withHarnessParameters({
|
||||
options: {
|
||||
...options,
|
||||
...(setting.voice ? { voice: setting.voice } : {}),
|
||||
},
|
||||
params: setting.params || {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,11 @@ export const SaveHarnessSettingsRequestSchema = z.object({
|
||||
fallback: z.boolean(),
|
||||
enabled: z.boolean(),
|
||||
voice: z.string().trim().max(200).optional(),
|
||||
/**
|
||||
* Generation settings (temperature, reasoning effort, ...).
|
||||
* Only the ones in `HarnessParameters` are kept.
|
||||
*/
|
||||
params: z.record(z.string(), z.union([z.number(), z.string()])).optional(),
|
||||
}),
|
||||
)
|
||||
.min(1, "Nothing to save")
|
||||
|
||||
@@ -258,6 +258,10 @@ export type HarnessModelOption = {
|
||||
name: string;
|
||||
free: boolean;
|
||||
voices?: string[];
|
||||
/**
|
||||
* The generation settings the model takes; undefined: all of them.
|
||||
*/
|
||||
supported_parameters?: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -325,6 +329,11 @@ export type HarnessFormValue = {
|
||||
fallback: boolean;
|
||||
enabled: boolean;
|
||||
voice?: string;
|
||||
/**
|
||||
* Generation settings (temperature, reasoning effort, ...), only the
|
||||
* ones the picked model takes.
|
||||
*/
|
||||
params?: Record<string, number | string>;
|
||||
};
|
||||
|
||||
export type HarnessSettingsFormObject = Partial<
|
||||
|
||||
Reference in New Issue
Block a user