Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
76aeb26
test(dashboard): reproduce engine page save reverts and 400s
woodsonl Sep 30, 2026
ea78466
test(dashboard): cover the ultra preview on a default install
woodsonl Sep 30, 2026
33aa7a7
fix(dashboard): engine pages save only the fields that changed
woodsonl Sep 30, 2026
e4a0059
refactor(dashboard): tighten the engine page save code
woodsonl Sep 30, 2026
7229b43
test(dashboard): reproduce stale forms and lost saves
woodsonl Oct 2, 2026
2bcf1d8
fix(dashboard): sync engine page forms with the server
woodsonl Oct 2, 2026
5f6420b
Merge remote-tracking branch 'upstream/release/v3.8.52' into fix/engi…
woodsonl Oct 2, 2026
853fb3d
test(dashboard): pin the engine page save retries and races
woodsonl Oct 5, 2026
ea900ba
fix(dashboard): apply engine form edits one field at a time
woodsonl Oct 5, 2026
662a92e
docs(changelog): note the engine page partial-save fix (#15595)
woodsonl Oct 5, 2026
04c3f0d
test(dashboard): reproduce emptied engine number fields saving zero
woodsonl Oct 5, 2026
fbc0e5d
fix(dashboard): treat an emptied engine number field as unset
woodsonl Oct 5, 2026
eb9bca6
test(dashboard): pin overflow rejection, failed saves and the emptied…
woodsonl Oct 5, 2026
31deae9
fix(dashboard): treat only NaN as an unset engine number
woodsonl Oct 5, 2026
4b62bca
feat(dashboard): show the schema default in emptied engine number fields
woodsonl Oct 5, 2026
7010d5f
test(dashboard): keep bad number entries from unsetting the stored value
woodsonl Oct 6, 2026
f646614
fix(dashboard): keep unparseable number entries and overflow previews…
woodsonl Oct 6, 2026
1dfd8bb
test(dashboard): pin the cap cleared while a save was in flight
woodsonl Oct 6, 2026
90cd770
docs(changelog): note the emptied engine number field fix (#15612)
woodsonl Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(dashboard):** Compression engine pages now save only the fields edited on that page. A save re-reads the stored settings and applies the edits onto the fresh stored copy, so saving an engine page no longer reverts thresholds and tool strategies the Compression Settings tab saved after the page loaded. An emptied text field clears its stored key, `ultra.enabled` survives engine-page saves, and the Ultra page saves on a default install where the model path was never set. (#15595)
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- **fix(dashboard):** compression engine config pages treat an emptied number field as "not set" instead of saving `0`, which silently changed compression for fields that accept zero (`aggressive.minSavingsThreshold`, `ultra.compressionRate`, `ultra.minScoreThreshold`) and 400'd saves for fields with a floor (`maxTokensPerMessage`, `minRows`, `minBlockChars`, `minChars`, `retrievalRampFactor`). Emptied fields now save as unset, show the schema default as a placeholder, keep an unparseable entry from unsetting the value, and reject overflow input visibly; lite's cap still clears when emptied.
1 change: 1 addition & 0 deletions src/i18n/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -8544,6 +8544,7 @@
"compressionEngineConfig": {
"loading": "Loading…",
"loadFailed": "Failed to load engine information.",
"settingsLoadFailed": "Failed to load the saved settings. The fields show defaults, and Save stays off until the page reloads.",
"engineNotFound": "Engine \"{engine}\" not found.",
"saveFailed": "Failed to save configuration.",
"previewFailed": "Preview failed.",
Expand Down
31 changes: 14 additions & 17 deletions src/shared/components/compression/EngineConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import type { EngineConfigField } from "@omniroute/open-sse/services/compression
export interface EngineConfigFormProps {
schema: EngineConfigField[];
value: Record<string, unknown>;
onChange: (next: Record<string, unknown>) => void;
// Called with one field at a time, so the caller can apply it to its latest state.
onChange: (key: string, value: unknown) => void;
}

export function EngineConfigForm({ schema, value, onChange }: EngineConfigFormProps) {
const set = (k: string, v: unknown) => onChange({ ...value, [k]: v });
export function EngineConfigForm({ schema, value, onChange: set }: EngineConfigFormProps) {
return (
<div className="flex flex-col gap-3">
{schema.map((f) => {
Expand All @@ -23,22 +23,19 @@ export function EngineConfigForm({ schema, value, onChange }: EngineConfigFormPr
{f.type === "number" && (
<input
type="number"
value={
f.key === "maxToolLength" &&
!(typeof v === "number" && Number.isFinite(v))
? ""
: (v as number)
}
// NaN (an emptied field, "not set") renders as an empty input, never as 0;
// overflow (Infinity) also renders empty and surfaces through the save error.
value={typeof v === "number" && Number.isFinite(v) ? v : ""}
placeholder={f.defaultValue != null ? String(f.defaultValue) : ""}
min={f.min}
max={f.max}
onChange={(e) =>
set(
f.key,
f.key === "maxToolLength" && e.target.value === ""
? Number.NaN
: Number(e.target.value)
)
}
onChange={(e) => {
// A browser reports badInput with an empty value for unparseable entries
// ("1e", "1,5" in a comma-decimal locale): keep the last valid value rather
// than mapping the entry to the unset sentinel.
if (e.target.value === "" && e.target.validity.badInput) return;
set(f.key, e.target.value === "" ? Number.NaN : Number(e.target.value));
}}
className="border border-border rounded px-2 py-1"
/>
)}
Expand Down
109 changes: 64 additions & 45 deletions src/shared/components/compression/EngineConfigPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { useEffect, useState } from "react";
import { useLocale, useTranslations } from "next-intl";
import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types";
import { EngineConfigForm } from "@/shared/components/compression/EngineConfigForm";
import {
buildEngineDetailUpdate,
forgetSentEdits,
formAfterSave,
seedEngineForm,
withoutEmptyText,
} from "@/shared/components/compression/engineConfigSave";

// ── Types ─────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -119,6 +126,9 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
// ── Data state ──────────────────────────────────────────────────────────
const [engine, setEngine] = useState<EngineEntry | null>(null);
const [configState, setConfigState] = useState<Record<string, unknown>>({});
// The stored values as of the last load or save, in form shape. A save sends the fields
// changed since.
const [savedConfig, setSavedConfig] = useState<Record<string, unknown>>({});
const [analytics, setAnalytics] = useState<Analytics | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -156,35 +166,26 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
.catch(() => null) as Promise<Analytics | null>,
]);

let foundEngine: EngineEntry | null = null;
if (enginesData) {
foundEngine = enginesData.engines?.find((e) => e.id === engineId) ?? null;
} else {
setLoadError(t("loadFailed"));
}
const foundEngine = enginesData?.engines?.find((e) => e.id === engineId) ?? null;

// Detailed config lives in the engine's settings sub-object (when it has one);
// the on/off + level moved to the panel. 404/null/missing = schema defaults.
// the on/off + level moved to the panel. A missing sub-object means schema defaults.
const subKey = SETTINGS_SUBOBJECT[engineId];
const stored = subKey ? settingsData?.[subKey] : undefined;
const currentConfig: Record<string, unknown> =
stored && typeof stored === "object" ? (stored as Record<string, unknown>) : {};

if (!cancelled) {
if (!enginesData) {
setLoadError(t("loadFailed"));
} else if (subKey && !settingsData) {
// Without the stored settings the form would show defaults as saved values, so
// Save stays off.
setLoadError(t("settingsLoadFailed"));
}
if (analyticsData) setAnalytics(analyticsData);
setEngine(foundEngine);
// Seed configState from defaultValues then override with the stored sub-object.
const defaults: Record<string, unknown> = {};
for (const field of foundEngine?.configSchema ?? []) {
defaults[field.key] = field.defaultValue;
}
// Do not seed lite.maxToolLength from the schema default. Persisting 2000
// would freeze the cap in settings and hide OMNIROUTE_LITE_MAX_TOOL_LENGTH.
// The form still shows 2000 via field.defaultValue until the operator edits it.
if (engineId === "lite" && currentConfig.maxToolLength === undefined) {
delete defaults.maxToolLength;
}
setConfigState({ ...defaults, ...currentConfig });
const seeded = seedEngineForm(engineId, foundEngine?.configSchema ?? [], stored);
setConfigState(seeded);
setSavedConfig(seeded);
setLoading(false);
}
}
Expand All @@ -207,38 +208,56 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
setSaveError(null);
return;
}
// Strip the `enabled` key — engine on/off is the panel's responsibility.
const { enabled: _ignored, ...formDetail } = configState;
void _ignored;
let detail: Record<string, unknown> = formDetail;
if (engineId === "lite") {
const raw = formDetail.maxToolLength;
const compressToolResults = formDetail.compressToolResults !== false;
if (!Object.prototype.hasOwnProperty.call(formDetail, "maxToolLength")) {
detail = { compressToolResults };
} else if (typeof raw === "number" && Number.isFinite(raw)) {
const n = Math.floor(raw);
if (n < 256 || n > 1_000_000) {
setSaveError(t("saveFailed"));
return;
}
detail = { compressToolResults, maxToolLength: n };
} else {
detail = { compressToolResults, maxToolLength: null };
}
// Lite's cap must be in range before anything is sent; the save floors it to a whole number.
// Only NaN (the emptied sentinel) skips the check — overflow like 1e999 fails the range below.
const cap = engineId === "lite" ? configState.maxToolLength : undefined;
if (
typeof cap === "number" &&
!Number.isNaN(cap) &&
(Math.floor(cap) < 256 || Math.floor(cap) > 1_000_000)
) {
setSaveError(t("saveFailed"));
return;
}
const sent = configState;
setSaving(true);
setSaveError(null);
try {
// The body starts from the copy stored now and changes only the fields edited here. The
// server replaces each sub-object whole (lite merges), so a copy this page loaded earlier
// would write back fields another page saved since.
const current = (await fetch("/api/settings/compression")
.then((r) => (r.ok ? r.json() : null))
.catch(() => null)) as CompressionSettings | null;
if (!current) {
setSaveError(t("saveFailed"));
return;
}
const detail = buildEngineDetailUpdate(engineId, savedConfig, sent, current[subKey]);
const res = await fetch("/api/settings/compression", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ [subKey]: detail }),
});
if (!res.ok) {
setSavedConfig((saved) => forgetSentEdits(saved, sent));
setSaveError(t("saveFailed"));
return;
}
// Show what the server now holds, which can include fields another page changed, and
// keep anything typed while the save was out.
const settings = (await res.json().catch(() => null)) as CompressionSettings | null;
const written = seedEngineForm(
engineId,
engine?.configSchema ?? [],
settings?.[subKey] ?? detail
);
setSavedConfig(written);
setConfigState((now) => formAfterSave(written, sent, now));
} catch {
// The PUT may have reached the server before the request failed, so the next save
// sends these fields again.
setSavedConfig((saved) => forgetSentEdits(saved, sent));
setSaveError(t("saveFailed"));
} finally {
setSaving(false);
Expand All @@ -256,15 +275,15 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
engineId === "headroom"
? {
headroom: {
...(typeof configState.minRows === "number"
...(typeof configState.minRows === "number" && !Number.isNaN(configState.minRows)
? { minRows: configState.minRows }
: {}),
},
}
: engineId === "aggressive"
? { aggressive: { ...configState } }
? { aggressive: withoutEmptyText(configState) }
: engineId === "ultra"
? { ultra: { ...configState } }
? { ultra: withoutEmptyText(configState) }
: undefined;
const res = await fetch("/api/compression/preview", {
method: "POST",
Expand Down Expand Up @@ -379,7 +398,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
<EngineConfigForm
schema={visibleConfigSchema}
value={configState}
onChange={setConfigState}
onChange={(key, next) => setConfigState((prev) => ({ ...prev, [key]: next }))}
/>
) : (
<p className="text-sm text-text-muted">{t("noAdditionalConfiguration")}</p>
Expand All @@ -388,7 +407,7 @@ export function EngineConfigPage({ engineId }: { engineId: string }) {
{persistable ? (
<button
onClick={handleSave}
disabled={saving}
disabled={saving || Boolean(loadError)}
className="px-4 py-1.5 rounded bg-primary text-primary-foreground text-sm font-medium disabled:opacity-50"
>
{saving ? t("saving") : t("save")}
Expand Down
15 changes: 3 additions & 12 deletions src/shared/components/compression/compressionPipelineModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ export type EngineIntensities = Record<string, readonly string[]>;
const FALLBACK_INTENSITIES: readonly string[] = ["standard"];

/** Intensities a given engine allows (falls back to `["standard"]` for unknown engines). */
export function allowedIntensities(
engine: string,
table: EngineIntensities
): readonly string[] {
export function allowedIntensities(engine: string, table: EngineIntensities): readonly string[] {
const list = table[engine];
return list && list.length > 0 ? list : FALLBACK_INTENSITIES;
}
Expand Down Expand Up @@ -54,11 +51,7 @@ export function addLayer(
}

/** Remove the layer at `index`, never dropping below `minLength` (default 1). */
export function removeLayer(
steps: PipelineStep[],
index: number,
minLength = 1
): PipelineStep[] {
export function removeLayer(steps: PipelineStep[], index: number, minLength = 1): PipelineStep[] {
if (steps.length <= minLength) return steps.slice();
if (index < 0 || index >= steps.length) return steps.slice();
return steps.filter((_, i) => i !== index);
Expand All @@ -72,7 +65,5 @@ export function updateLayer(
table: EngineIntensities
): PipelineStep[] {
if (index < 0 || index >= steps.length) return steps.slice();
return steps.map((step, i) =>
i === index ? normalizeStep({ ...step, ...patch }, table) : step
);
return steps.map((step, i) => (i === index ? normalizeStep({ ...step, ...patch }, table) : step));
}
104 changes: 104 additions & 0 deletions src/shared/components/compression/engineConfigSave.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import type { EngineConfigField } from "@omniroute/open-sse/services/compression/engines/types";

type FormValues = Record<string, unknown>;

function asRecord(value: unknown): FormValues {
return value && typeof value === "object" ? (value as FormValues) : {};
}

// The settings schemas reject an empty string, so an emptied text field means "not set".
function isEmptyText(value: unknown): boolean {
return typeof value === "string" && value.trim() === "";
}

/** Form values an engine page shows: schema defaults, then the stored sub-object. */
export function seedEngineForm(
engineId: string,
schema: EngineConfigField[],
stored: unknown
): FormValues {
const current = asRecord(stored);
const defaults: FormValues = Object.fromEntries(schema.map((f) => [f.key, f.defaultValue]));
// Leave lite.maxToolLength out until a cap is stored. The baseline then has no cap, so an
// unset cap stays unset (OMNIROUTE_LITE_MAX_TOOL_LENGTH applies) and typing 2000 still counts
// as an edit. The form still shows 2000 through field.defaultValue.
if (engineId === "lite" && current.maxToolLength === undefined) {
delete defaults.maxToolLength;
}
return { ...defaults, ...current };
}

/**
* The sub-object an engine page PUTs on save. The server replaces each sub-object row whole
* (lite merges), so the body starts from the copy stored at save time and applies only the fields
* edited since `saved`. The page hides `enabled`, so the form never writes it and the stored value
* passes through. An emptied text field removes its key, and an emptied number field (NaN) leaves
* the body as not set — except lite's cap, which maps to null below.
*/
export function buildEngineDetailUpdate(
engineId: string,
saved: FormValues,
edited: FormValues,
stored: unknown
): FormValues {
const next = { ...asRecord(stored) };
for (const [key, value] of Object.entries(edited)) {
if (key === "enabled" || Object.is(value, saved[key])) continue;
if (isEmptyText(value)) {
delete next[key];
} else if (typeof value === "number" && Number.isNaN(value)) {
// An emptied number input is the only source of NaN: it means "not set" and leaves the
// body, except lite's cap, which the lite merge below maps to null to drop the stored cap.
// Overflow (Infinity) is a value, not an unset: it stays, and a non-lite schema rejects the
// wire-null it serializes to. lite's cap null IS the clear sentinel, so only the page's cap
// range guard stops an overflow cap from silently clearing the stored cap.
if (engineId !== "lite" || key !== "maxToolLength") delete next[key];
else next[key] = value;
} else {
next[key] = value;
}
}
if (engineId !== "lite") return next;
// The lite settings schema accepts only these two fields. A cleared cap input holds NaN, and
// null tells the server to drop the stored cap.
const cap = next.maxToolLength;
return {
compressToolResults: next.compressToolResults !== false,
...("maxToolLength" in next
? { maxToolLength: typeof cap === "number" && Number.isNaN(cap) ? null : Math.floor(cap) }
: {}),
};
}

/** The form after a successful save: what the server now holds, plus edits made meanwhile. */
export function formAfterSave(written: FormValues, sent: FormValues, now: FormValues): FormValues {
const next = { ...written };
for (const [key, value] of Object.entries(now)) {
if (!Object.is(value, sent[key])) next[key] = value;
}
return next;
}

/**
* The baseline after a failed save. The server may have applied the save before the request
* failed, so the fields it carried leave the baseline and the next save sends them again.
*/
export function forgetSentEdits(saved: FormValues, sent: FormValues): FormValues {
const next = { ...saved };
for (const [key, value] of Object.entries(sent)) {
if (!Object.is(value, saved[key])) delete next[key];
}
return next;
}

/**
* Form values without emptied text fields or emptied number fields (NaN), so a preview config
* passes the settings schema. Overflow values stay, so the schema rejects them visibly.
*/
export function withoutEmptyText(values: FormValues): FormValues {
return Object.fromEntries(
Object.entries(values).filter(
([, value]) => !isEmptyText(value) && !(typeof value === "number" && Number.isNaN(value))
)
);
}
Loading
Loading