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:
2026-10-22 04:52:55 +01:00
parent fe502b38fe
commit c2553a4746
25 changed files with 644 additions and 13 deletions
+6
View File
@@ -1563,6 +1563,12 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
dataType: "TEXT",
json: true,
},
{
fieldName: "supported_parameters",
fieldDescription: `JSON array of the request fields the model takes (OpenRouter's supported_parameters), so only the generation settings it honors are offered`,
dataType: "TEXT",
json: true,
},
{
fieldName: "intelligence_score",
fieldDescription: `Artificial Analysis intelligence index`,
+4
View File
@@ -941,6 +941,10 @@ export type BUN_SQLITE_COBBLE_HARNESS_MODELS = {
* JSON array of voice names, for speech models
*/
voices?: string;
/**
* JSON array of the request fields the model takes (OpenRouter's supported_parameters), so only the generation settings it honors are offered
*/
supported_parameters?: string;
/**
* Artificial Analysis intelligence index
*/
+51
View File
@@ -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);
});
});
+174
View File
@@ -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,
+14
View File
@@ -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_
],
},
],
/**
* 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,
+15 -5
View File
@@ -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,
});
+56
View File
@@ -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
View File
@@ -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 || {},
}),
});
}
+8 -1
View File
@@ -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 || {},
}),
});
}
+5
View File
@@ -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")
+9
View File
@@ -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<