/**
* SetupTab/ThresholdsPanel.tsx — per-model compaction thresholds (S52 / v0.16.1).
*
* Lists every known model (from model_snapshots) with two sliders each:
* - Safety Margin (0-20%): reserved after summary + maxOutput. Lower =
* more usable tail but risks the "too long even after compaction" error.
* - Fire Point (10-90%): % of window where compaction fires. Lower = compact
* earlier (more headroom); higher = more context before compaction.
*
* Warnings fire on <5% safety (overflow risk) and >85% fire point (aggressive
* — may hit the cap before compaction triggers). Changes save immediately via
* PUT /api/model-thresholds; a "Reset to default" button DELETEs the override.
*
* PREVENT-PI-004: all data comes from localhost dashboard API endpoints.
*/
import type React from "react";
import { useState, useEffect, useCallback } from "react";
import {
fetchModelThresholds,
putModelThreshold,
deleteModelThreshold,
} from "../../api/client";
import type { ModelThresholdsResponse } from "@contracts";
interface ModelRow {
readonly modelId: string;
readonly provider: string;
readonly modelName: string | null;
readonly contextWindow: number;
readonly maxTokens: number;
readonly hasOverride: boolean;
readonly threshold: {
readonly safetyMarginPct: number;
readonly firePointPct: number;
readonly isOverride: boolean;
};
}
function fmtWindow(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
if (n >= 1000) return `${Math.round(n / 1000)}K`;
return String(n);
}
/** Live preview of the usable tail budget for a model + slider values. */
function usableTail(
ctxWindow: number,
maxTokens: number,
safetyPct: number,
): string {
if (ctxWindow <= 0) return "—";
const maxOut = maxTokens > 0 ? maxTokens : Math.ceil(ctxWindow * 0.1);
const reserve = maxOut + Math.ceil(ctxWindow * (safetyPct / 100));
const usable = ctxWindow - reserve;
if (usable <= 0) return "0 (at risk)";
return `${fmtWindow(usable)} tokens`;
}
function ThresholdSlider({
label,
value,
min,
max,
step = 1,
onChange,
warning,
}: {
readonly label: string;
readonly value: number;
readonly min: number;
readonly max: number;
readonly step?: number;
readonly onChange: (v: number) => void;
readonly warning?: string;
}): React.ReactElement {
return (
{label}
{value}%
onChange(Number(e.target.value))}
className="threshold-slider w-full"
/>
{warning &&
⚠ {warning}
}
);
}
function ModelThresholdRow({
model,
defaults,
safetyRange,
fireRange,
onSave,
onReset,
saving,
}: {
readonly model: ModelRow;
readonly defaults: { safetyMarginPct: number; firePointPct: number };
readonly safetyRange: readonly [number, number];
readonly fireRange: readonly [number, number];
readonly onSave: (
modelId: string,
safetyMarginPct: number,
firePointPct: number,
) => Promise;
readonly onReset: (modelId: string) => Promise;
readonly saving: boolean;
}): React.ReactElement {
const [safety, setSafety] = useState(model.threshold.safetyMarginPct);
const [fire, setFire] = useState(model.threshold.firePointPct);
const [dirty, setDirty] = useState(false);
const [err, setErr] = useState(null);
// Re-sync when the server data changes (e.g. after reset).
useEffect(() => {
setSafety(model.threshold.safetyMarginPct);
setFire(model.threshold.firePointPct);
setDirty(false);
}, [model.threshold.safetyMarginPct, model.threshold.firePointPct]);
const safetyWarning =
safety < 5
? `Low safety margin — risks "conversation too long" errors when a single turn injects a large tool output between gate fires.`
: undefined;
const fireWarning =
fire > 85
? `High fire point — compaction may trigger close to the window cap; a single large turn can overshoot before the gate fires.`
: undefined;
const save = useCallback(async () => {
setErr(null);
try {
await onSave(model.modelId, safety, fire);
setDirty(false);
} catch (e) {
setErr(e instanceof Error ? e.message : String(e));
}
}, [model.modelId, safety, fire, onSave]);
return (
{model.modelName ?? model.modelId}
{model.threshold.isOverride && (
Override
)}
{model.provider} · {fmtWindow(model.contextWindow)} window ·{" "}
{fmtWindow(model.maxTokens)} output
Usable tail:{" "}
{usableTail(model.contextWindow, model.maxTokens, safety)}
Compaction fires at:{" "}
{fmtWindow(Math.ceil(model.contextWindow * (fire / 100)))}
{
setSafety(v);
setDirty(true);
}}
warning={safetyWarning}
/>
{
setFire(v);
setDirty(true);
}}
warning={fireWarning}
/>
{dirty ? "Save override" : "Saved"}
{model.threshold.isOverride && (
onReset(model.modelId)}
disabled={saving}
className="rounded-md border border-border px-2.5 py-1 text-xs text-fg-muted disabled:cursor-not-allowed disabled:opacity-50"
>
Reset to default ({defaults.safetyMarginPct}% /{" "}
{defaults.firePointPct}%)
)}
{err && {err} }
);
}
export default function ThresholdsPanel(): React.ReactElement {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [saving, setSaving] = useState(false);
const load = useCallback(async () => {
try {
setError(null);
setData(await fetchModelThresholds());
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const onSave = useCallback(
async (modelId: string, safetyMarginPct: number, firePointPct: number) => {
setSaving(true);
try {
await putModelThreshold({ modelId, safetyMarginPct, firePointPct });
await load();
} finally {
setSaving(false);
}
},
[load],
);
const onReset = useCallback(
async (modelId: string) => {
setSaving(true);
try {
await deleteModelThreshold(modelId);
await load();
} finally {
setSaving(false);
}
},
[load],
);
if (loading)
return Loading thresholds…
;
if (error)
return Error: {error}
;
if (!data) return No data.
;
return (
Per-Model Compaction Thresholds
Tune when compaction fires + how much safety margin to reserve,
per model . Different providers' models range from 8K
to 1M+ context — one global % is wrong. Defaults:{" "}
{data.defaults.safetyMarginPct}% safety
{" "}
/{" "}
{data.defaults.firePointPct}% fire .
Values outside the safe band show warnings.
{data.models.length === 0 ? (
No models captured yet. Start a pi session in any repo to populate
model snapshots, then return here to tune per-model thresholds.
) : (
{data.models.map((m) => (
))}
)}
);
}