diff --git a/db/schema.ts b/db/schema.ts index 6f4aafb..157bbdf 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -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`, diff --git a/db/types/db.ts b/db/types/db.ts index e60f694..3eeec58 100644 --- a/db/types/db.ts +++ b/db/types/db.ts @@ -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 */ diff --git a/src/dict/harness-parameter-dict.test.ts b/src/dict/harness-parameter-dict.test.ts new file mode 100644 index 0000000..0bd5913 --- /dev/null +++ b/src/dict/harness-parameter-dict.test.ts @@ -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); + }); +}); diff --git a/src/dict/harness-parameter-dict.ts b/src/dict/harness-parameter-dict.ts new file mode 100644 index 0000000..46f079b --- /dev/null +++ b/src/dict/harness-parameter-dict.ts @@ -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; +}): Partial> { + 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; + params: Partial>; +}): Record { + const rest: Record = 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, + ); +} diff --git a/src/functions/backend/db/harness/grab-harness-model-entries.ts b/src/functions/backend/db/harness/grab-harness-model-entries.ts index 3d789ae..0d921fe 100644 --- a/src/functions/backend/db/harness/grab-harness-model-entries.ts +++ b/src/functions/backend/db/harness/grab-harness-model-entries.ts @@ -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, }, }; }); diff --git a/src/functions/backend/db/harness/harness-model-row-to-catalog.ts b/src/functions/backend/db/harness/harness-model-row-to-catalog.ts index 23cb870..918f203 100644 --- a/src/functions/backend/db/harness/harness-model-row-to-catalog.ts +++ b/src/functions/backend/db/harness/harness-model-row-to-catalog.ts @@ -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), diff --git a/src/functions/backend/db/harness/upsert-harness-models.ts b/src/functions/backend/db/harness/upsert-harness-models.ts index 70178c8..2f47796 100644 --- a/src/functions/backend/db/harness/upsert-harness-models.ts +++ b/src/functions/backend/db/harness/upsert-harness-models.ts @@ -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, diff --git a/src/harness/media/read-image.ts b/src/harness/media/read-image.ts index 2ddfca2..9cc1a7b 100644 --- a/src/harness/media/read-image.ts +++ b/src/harness/media/read-image.ts @@ -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, }); diff --git a/src/harness/providers/openai-compatible/chat.ts b/src/harness/providers/openai-compatible/chat.ts index 1405e8d..91ee224 100644 --- a/src/harness/providers/openai-compatible/chat.ts +++ b/src/harness/providers/openai-compatible/chat.ts @@ -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, diff --git a/src/harness/providers/openrouter/openrouter-model-to-catalog.test.ts b/src/harness/providers/openrouter/openrouter-model-to-catalog.test.ts index 430a1f4..45b82d3 100644 --- a/src/harness/providers/openrouter/openrouter-model-to-catalog.test.ts +++ b/src/harness/providers/openrouter/openrouter-model-to-catalog.test.ts @@ -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, diff --git a/src/harness/providers/openrouter/openrouter-model-to-catalog.ts b/src/harness/providers/openrouter/openrouter-model-to-catalog.ts index 3a32e4b..1fdb8e3 100644 --- a/src/harness/providers/openrouter/openrouter-model-to-catalog.ts +++ b/src/harness/providers/openrouter/openrouter-model-to-catalog.ts @@ -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, diff --git a/src/harness/text/generate-text.ts b/src/harness/text/generate-text.ts index 059df7e..4c0e0c1 100644 --- a/src/harness/text/generate-text.ts +++ b/src/harness/text/generate-text.ts @@ -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; 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 { + 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, }); diff --git a/src/harness/text/grab-chat-params.ts b/src/harness/text/grab-chat-params.ts new file mode 100644 index 0000000..46a3412 --- /dev/null +++ b/src/harness/text/grab-chat-params.ts @@ -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 +> { + 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], + ]; + }), + ); +} diff --git a/src/harness/types.ts b/src/harness/types.ts index b325d33..6ad4a29 100644 --- a/src/harness/types.ts +++ b/src/harness/types.ts @@ -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; signal: AbortSignal; }; diff --git a/src/pages/admin/settings/models/(partials)/harness-parameter-control.tsx b/src/pages/admin/settings/models/(partials)/harness-parameter-control.tsx new file mode 100644 index 0000000..d861e3e --- /dev/null +++ b/src/pages/admin/settings/models/(partials)/harness-parameter-control.tsx @@ -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 ( + { + if (next.trim() == "") { + onChange(undefined); + return; + } + + const number = Number(next); + + if (!Number.isNaN(number)) onChange(number); + }} + /> + ); +} diff --git a/src/pages/admin/settings/models/(partials)/harness-parameter-fields.tsx b/src/pages/admin/settings/models/(partials)/harness-parameter-fields.tsx new file mode 100644 index 0000000..a35e1fb --- /dev/null +++ b/src/pages/admin/settings/models/(partials)/harness-parameter-fields.tsx @@ -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; + onChange: (params: Record) => 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) => ( + { + const next = { ...params }; + + if (value === undefined) delete next[parameter.key]; + else next[parameter.key] = value; + + onChange(next); + }} + /> + ))} + + ); +} diff --git a/src/pages/admin/settings/models/(partials)/harness-parameter-modal.tsx b/src/pages/admin/settings/models/(partials)/harness-parameter-modal.tsx new file mode 100644 index 0000000..0629dd5 --- /dev/null +++ b/src/pages/admin/settings/models/(partials)/harness-parameter-modal.tsx @@ -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; + onChange: (params: Record) => 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 ( + + + Model settings + + + {set.length + ? set.join(" ยท ") + : "Temperature, reasoning effort, sampling limits"} + + + } + 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" + > + +

+ {model.name} settings +

+ + How this model answers. Empty leaves the provider's own + default; a lower temperature keeps a bot literal and + repeatable. + +
+ + + +
+ ); +} diff --git a/src/pages/admin/settings/models/(partials)/harness-setting-group.tsx b/src/pages/admin/settings/models/(partials)/harness-setting-group.tsx index a47d5cd..bdc08fa 100644 --- a/src/pages/admin/settings/models/(partials)/harness-setting-group.tsx +++ b/src/pages/admin/settings/models/(partials)/harness-setting-group.tsx @@ -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 ? ( + onChange({ ...value, params })} + /> + ) : null} {!value.inherit && harness.harness == "speech" && voices?.length ? ( ; +}) { + const entries = Object.entries(params || {}).sort(([a], [b]) => + a.localeCompare(b), + ); + + return JSON.stringify(entries); +} diff --git a/src/pages/admin/settings/models/(utils)/to-harness-form.ts b/src/pages/admin/settings/models/(utils)/to-harness-form.ts index db79222..3c96f0b 100644 --- a/src/pages/admin/settings/models/(utils)/to-harness-form.ts +++ b/src/pages/admin/settings/models/(utils)/to-harness-form.ts @@ -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, + }), }, ]), ); diff --git a/src/pages/api/admin/bots/save-harness-settings.ts b/src/pages/api/admin/bots/save-harness-settings.ts index 8c66f4c..bf5ca14 100644 --- a/src/pages/api/admin/bots/save-harness-settings.ts +++ b/src/pages/api/admin/bots/save-harness-settings.ts @@ -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 || {}, + }), }); } diff --git a/src/pages/api/admin/harness/save-settings.ts b/src/pages/api/admin/harness/save-settings.ts index d1e9923..b99a879 100644 --- a/src/pages/api/admin/harness/save-settings.ts +++ b/src/pages/api/admin/harness/save-settings.ts @@ -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 || {}, + }), }); } diff --git a/src/pages/api/admin/workspaces/save-harness-settings.ts b/src/pages/api/admin/workspaces/save-harness-settings.ts index ee418d0..b7d8c15 100644 --- a/src/pages/api/admin/workspaces/save-harness-settings.ts +++ b/src/pages/api/admin/workspaces/save-harness-settings.ts @@ -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 || {}, + }), }); } diff --git a/src/schemas/harness-schema.ts b/src/schemas/harness-schema.ts index 6a88bad..5d20c8d 100644 --- a/src/schemas/harness-schema.ts +++ b/src/schemas/harness-schema.ts @@ -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") diff --git a/src/types/index.ts b/src/types/index.ts index 0561319..08c1aaa 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; }; export type HarnessSettingsFormObject = Partial<