Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
Expand Up @@ -554,6 +554,15 @@ requests use. The setting is disabled when omitted.
```

`model` accepts native model IDs, provider-qualified model IDs, and configured combos.
Optional `sourceModels` limits the override to exact incoming model IDs or `provider/*` patterns,
for example `["kimi/*", "google-antigravity/*"]`. Matching is case-sensitive. Omit it for the
existing all-model behavior; empty, duplicate, or malformed lists are invalid. Dashboard saves
preserve this allowlist and display it read-only; configure it through `config.json` or `PUT /api/settings`.
This checks the incoming compaction model, not an inferred conversation model. If Codex sends a
bare native compaction model for a routed thread, it does not match those provider patterns and
keeps its existing route. This conservative behavior protects GPT but can leave some automatic
compactions on the native path. Ordinary non-compaction turns are never changed.

`reasoningEffort` is optional; omit it to preserve the incoming effort. Supported declarations
are `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`, and `ultra`.
Existing provider effort rules still apply. The native `/responses/compact` endpoint keeps
Expand Down
148 changes: 126 additions & 22 deletions gui/src/components/CompactionRoutingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { requireJson, type ModelInfo } from "../pages/dashboard-shared";
import { comboModelId, parseComboList } from "../combo-workspace-data";
import { formatNamespacedModelId } from "../provider-icons";

type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null;
type Setting = { model: string; reasoningEffort?: string; triggers?: string[]; sourceModels?: string[] } | null;
const EFFORTS = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"];
const TRIGGERS = ["manual", "auto"];
/**
Expand All @@ -20,6 +20,16 @@ const TRIGGER_LABELS: Record<string, TKey> = {
"manual+auto": "compactionRouting.triggersBoth",
"auto": "compactionRouting.triggersAuto",
};
/**
* "all" is sent as an omitted `sourceModels`, so the default save keeps the exact payload the
* unscoped override used. `selected` requires at least one checked selector: the config schema
* rejects an empty list and would silently drop the whole override.
*/
const SCOPE_CHOICES = ["all", "selected"] as const;
const SCOPE_LABELS: Record<string, TKey> = {
"all": "compactionRouting.sourcesAll",
"selected": "compactionRouting.sourcesSelected",
};

function triggersToChoice(value: string[] | undefined): string {
if (!value) return "manual";
Expand Down Expand Up @@ -62,26 +72,47 @@ function readSetting(payload: { compactionRouting?: unknown }): Setting {
const effort = "reasoningEffort" in value ? value.reasoningEffort : undefined;
if (effort !== undefined && (typeof effort !== "string" || !EFFORTS.includes(effort))) throw new Error("invalid effort");
const triggers = "triggers" in value ? value.triggers : undefined;
const sourceModels = "sourceModels" in value ? value.sourceModels : undefined;
if (sourceModels !== undefined && (!Array.isArray(sourceModels) || !sourceModels.length
|| !sourceModels.every(entry => typeof entry === "string" && entry.trim()))) throw new Error("invalid sourceModels");
if (triggers !== undefined && (!Array.isArray(triggers) || triggers.length === 0
|| !triggers.every(entry => typeof entry === "string" && TRIGGERS.includes(entry))
|| new Set(triggers).size !== triggers.length)) throw new Error("invalid triggers");
return {
model: value.model,
...(effort ? { reasoningEffort: effort as string } : {}),
...(triggers ? { triggers: triggers as string[] } : {}),
...(sourceModels ? { sourceModels: sourceModels as string[] } : {}),
};
}

export default function CompactionRoutingPanel(props: { apiBase: string; models: ModelInfo[] }) {
/** A provider name says who is configured; the endpoint host says where bytes actually go. */
function hostOf(baseUrl: string): string {
if (!baseUrl) return "";
try {
return new URL(baseUrl).hostname || baseUrl;
} catch {
return baseUrl;
}
}

function endpointLabel(name: string, hosts: ReadonlyMap<string, string>): string {
const host = hosts.get(name);
return host ? `${name} (${host})` : name;
}

export default function CompactionRoutingPanel(props: { apiBase: string; models: ModelInfo[]; providers?: Array<{ name: string; baseUrl: string }> }) {
return <CompactionRoutingControls key={props.apiBase} {...props} />;
}

function CompactionRoutingControls({ apiBase, models }: { apiBase: string; models: ModelInfo[] }) {
function CompactionRoutingControls({ apiBase, models, providers: providerList = [] }: { apiBase: string; models: ModelInfo[]; providers?: Array<{ name: string; baseUrl: string }> }) {
const t = useT();
const [saved, setSaved] = useState<Setting | undefined>(undefined);
const [model, setModel] = useState("");
const [effort, setEffort] = useState("");
const [triggers, setTriggers] = useState("manual");
const [scope, setScope] = useState("all");
const [sources, setSources] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [loadError, setLoadError] = useState(false);
const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null);
Expand All @@ -94,8 +125,17 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
setModel(value?.model ?? "");
setEffort(value?.reasoningEffort ?? "");
setTriggers(triggersToChoice(value?.triggers));
setScope(value?.sourceModels ? "selected" : "all");
setSources(value?.sourceModels ?? []);
}, []);

const toggleSource = (selector: string) => {
setSources(current => current.includes(selector)
? current.filter(entry => entry !== selector)
: [...current, selector]);
setFeedback(null);
};

const load = useCallback(async () => {
if (pending.current) return;
const request = createBoundedFetch(15_000);
Expand Down Expand Up @@ -144,6 +184,7 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
model,
...(effort ? { reasoningEffort: effort } : {}),
...(choiceToTriggers(triggers) ? { triggers: choiceToTriggers(triggers) } : {}),
...(scope === "selected" && sources.length > 0 ? { sourceModels: sources } : {}),
}
: null,
}),
Expand All @@ -166,10 +207,28 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
const options = [{ value: "", label: t("compactionRouting.currentModel") },
...[...new Set([...models.map(item => item.namespaced), ...(model ? [model] : [])])]
.map(value => ({ value, label: formatNamespacedModelId(value, t) }))];
const providerWildcards = [...new Set(models.map(item => item.provider).filter(Boolean))].map(provider => `${provider}/*`);
const knownSelectors = new Set([...providerWildcards, ...models.map(item => item.namespaced)]);
// Saved selectors the catalog no longer lists stay visible, so saving never drops them by
// omission and the operator removes them deliberately.
const savedOnlySelectors = (saved?.sourceModels ?? []).filter(selector => !knownSelectors.has(selector));
const disabled = busy || saved === undefined || loadError;
const savedScope = saved?.sourceModels ? "selected" : "all";
const dirty = model !== (saved?.model ?? "")
|| effort !== (saved?.reasoningEffort ?? "")
|| triggers !== triggersToChoice(saved?.triggers);
|| triggers !== triggersToChoice(saved?.triggers)
|| scope !== savedScope
|| (scope === "selected"
&& (sources.length !== (saved?.sourceModels?.length ?? 0)
|| sources.some(selector => !saved?.sourceModels?.includes(selector))));
const scopeEmpty = scope === "selected" && sources.length === 0;
const sourceRow = (selector: string) => (
<label key={selector}>
<input type="checkbox" checked={sources.includes(selector)} disabled={disabled}
onChange={() => { toggleSource(selector); }} />
<span>{selector}</span>
</label>
);
// Ask what the selection resolves to instead of reading its name. An aliased combo answers
// here exactly like a prefixed one (#5216).
const comboTargets = comboProviders.get(model);
Expand All @@ -178,9 +237,21 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
// an ordinary provider named "combo" — worse than the alias gap this fixes.
const isCombo = comboTargets !== undefined || model.startsWith(comboModelId(""));
const namespace = model.slice(0, Math.max(model.indexOf("/"), 0));
const provider = isCombo ? "" : (namespace || model);
const providers = comboTargets?.join(", ") || t("compactionRouting.comboProvidersUnknown");
const endpointHosts = new Map(providerList.map(entry => [entry.name, hostOf(entry.baseUrl)] as const).filter(([, host]) => host));
const provider = isCombo ? "" : endpointLabel(namespace || model, endpointHosts);
const providers = comboTargets?.map(name => endpointLabel(name, endpointHosts)).join(", ") || t("compactionRouting.comboProvidersUnknown");
const routesAutomatic = triggers !== "manual";
const scopedSources = sources.join(", ");
// A scoped override only covers the checked sources, so the disclosure names them instead of
// claiming every request; an empty selection covers nothing and shows no destination claim.
const warningText = !model || scopeEmpty ? null
: isCombo
? (scope === "selected"
? t("compactionRouting.comboWarningScoped", { combo: model, providers, sources: scopedSources })
: t("compactionRouting.comboWarning", { combo: model, providers }))
: (scope === "selected"
? t("compactionRouting.providerWarningScoped", { provider, sources: scopedSources })
: t("compactionRouting.providerWarning", { provider }));

return (
<section className="panel" aria-labelledby="compaction-routing-title" aria-busy={busy || (saved === undefined && !loadError)}>
Expand All @@ -190,27 +261,60 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
<div className="muted setting-hint">{t("compactionRouting.description")}</div>
<div className="muted setting-hint">{t("compactionRouting.dataNotice")}</div>
<div className="muted setting-hint">{t("compactionRouting.effortHint")}</div>
<div className="muted setting-hint">{t("compactionRouting.sourcesHint")}</div>
</div>
<div className="dash-delegation-controls" style={{ flex: "0 1 auto" }}>
<Select id="compaction-routing-model" value={model} options={options} disabled={disabled}
label={t("compactionRouting.model")}
onChange={value => { setModel(value); if (!value) { setEffort(""); setTriggers("manual"); } setFeedback(null); }} />
<Select id="compaction-routing-triggers" value={triggers} disabled={disabled || !model} align="right"
label={t("compactionRouting.triggers")}
options={TRIGGER_CHOICES.map(value => ({ value, label: t(TRIGGER_LABELS[value]!) }))}
onChange={value => { setTriggers(value); setFeedback(null); }} />
<Select id="compaction-routing-effort" value={effort} disabled={disabled || !model} align="right"
label={t("compactionRouting.effort")}
options={[{ value: "", label: t("compactionRouting.currentEffort") }, ...EFFORTS.map(value => ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]}
onChange={value => { setEffort(value); setFeedback(null); }} />
<button type="button" className="btn btn-primary btn-sm" disabled={disabled || !dirty} onClick={() => { void save(); }}>
<div className="dash-delegation-controls compaction-routing-controls" style={{ flex: "0 1 auto" }}>
<div className="compaction-field">
<label className="field-label" htmlFor="compaction-routing-model">{t("compactionRouting.model")}</label>
<Select id="compaction-routing-model" value={model} options={options} disabled={disabled}
label={t("compactionRouting.model")}
onChange={value => { setModel(value); if (!value) { setEffort(""); setTriggers("manual"); setScope("all"); setSources([]); } setFeedback(null); }} />
</div>
<div className="compaction-field">
<label className="field-label" htmlFor="compaction-routing-triggers">{t("compactionRouting.triggers")}</label>
<Select id="compaction-routing-triggers" value={triggers} disabled={disabled || !model} align="right"
label={t("compactionRouting.triggers")}
options={TRIGGER_CHOICES.map(value => ({ value, label: t(TRIGGER_LABELS[value]!) }))}
onChange={value => { setTriggers(value); setFeedback(null); }} />
</div>
<div className="compaction-field">
<label className="field-label" htmlFor="compaction-routing-sources">{t("compactionRouting.sources")}</label>
<Select id="compaction-routing-sources" value={scope} disabled={disabled || !model} align="right"
label={t("compactionRouting.sources")}
options={SCOPE_CHOICES.map(value => ({ value, label: t(SCOPE_LABELS[value]!) }))}
onChange={value => { setScope(value); setFeedback(null); }} />
</div>
<div className="compaction-field">
<label className="field-label" htmlFor="compaction-routing-effort">{t("compactionRouting.effort")}</label>
<Select id="compaction-routing-effort" value={effort} disabled={disabled || !model} align="right"
label={t("compactionRouting.effort")}
options={[{ value: "", label: t("compactionRouting.currentEffort") }, ...EFFORTS.map(value => ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]}
onChange={value => { setEffort(value); setFeedback(null); }} />
</div>
<button type="button" className="btn btn-primary btn-sm" disabled={disabled || !dirty || scopeEmpty} onClick={() => { void save(); }}>
{busy ? t("common.saving") : t("common.save")}
</button>
</div>
</div>
{model && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {isCombo
? t("compactionRouting.comboWarning", { combo: model, providers })
: t("compactionRouting.providerWarning", { provider })}</div>}
{model && scope === "selected" && <div className="compaction-sources-block">
<div className="field-label compaction-sources-title">{t("compactionRouting.sourcesGridTitle")}</div>
<div className="source-scope-groups">
{providerWildcards.length > 0 && <fieldset className="source-scope-group">
<legend className="field-label">{t("compactionRouting.sourcesProviders")}</legend>
{providerWildcards.map(sourceRow)}
</fieldset>}
{models.length > 0 && <fieldset className="source-scope-group">
<legend className="field-label">{t("compactionRouting.sourcesModels")}</legend>
{models.map(item => item.namespaced).map(sourceRow)}
</fieldset>}
{savedOnlySelectors.length > 0 && <fieldset className="source-scope-group">
<legend className="field-label">{t("compactionRouting.sourcesSaved")}</legend>
{savedOnlySelectors.map(sourceRow)}
</fieldset>}
{scopeEmpty && <div className="muted setting-hint">{t("compactionRouting.sourcesNone")}</div>}
</div>
</div>}
{warningText && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {warningText}</div>}
{model && routesAutomatic && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {t("compactionRouting.autoNotice")}</div>}
{loadError && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("compactionRouting.loadFailed")} <button type="button" className="btn btn-ghost btn-sm" onClick={() => { void load(); }}>{t("common.retry")}</button></div>}
{feedback === "failed" && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("compactionRouting.saveFailed")}</div>}
Expand Down
11 changes: 11 additions & 0 deletions gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,15 @@ export const de: Record<TKey, string> = {
"compactionRouting.triggersManual": "Nur manuelles /compact",
"compactionRouting.triggersBoth": "Manuell und automatisch",
"compactionRouting.triggersAuto": "Nur automatisch",
"compactionRouting.sources": "Quellen",
"compactionRouting.sourcesAll": "Alle Konversationsmodelle",
"compactionRouting.sourcesSelected": "Nur ausgewählte Quellen",
"compactionRouting.sourcesProviders": "Ganze Anbieter",
"compactionRouting.sourcesModels": "Einzelne Modelle",
"compactionRouting.sourcesSaved": "Gespeicherte Selektoren außerhalb des aktuellen Katalogs",
"compactionRouting.sourcesNone": "Wähle mindestens eine Quelle oder wechsle zurück zu allen Konversationsmodellen.",
"compactionRouting.sourcesHint": "Schränkt die Überschreibung auf Komprimierungsanfragen ausgewählter Konversationen ein. provider/* deckt alle Modelle dieses Anbieters ab; nicht aufgeführte Konversationen nutzen weiterhin ihr eigenes Modell.",
"compactionRouting.sourcesGridTitle": "Komprimierungsanfragen aus diesen Quellen umleiten:",
"compactionRouting.currentModel": "Gesprächsmodell verwenden",
"compactionRouting.currentEffort": "Anfrageaufwand beibehalten",
"compactionRouting.effortHint": "Der Denkaufwand gilt, wenn der Komprimierungsendpunkt ihn unterstützt. Das Modell muss das gesamte Gespräch verarbeiten können.",
Expand All @@ -377,6 +386,8 @@ export const de: Record<TKey, string> = {
"compactionRouting.providerWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}.",
"compactionRouting.comboWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an die Combo {combo}. Die Combo versucht ihre Ziele ({providers}) der Reihe nach und verwendet das erste, das antwortet, sodass jedes davon das Gespräch erhalten kann.",
"compactionRouting.comboProvidersUnknown": "ihre konfigurierten Zielanbieter",
"compactionRouting.providerWarningScoped": "Mit dieser Einstellung senden nur Komprimierungsanfragen von {sources} den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}; andere Gespräche nutzen weiterhin ihr eigenes Modell.",
"compactionRouting.comboWarningScoped": "Mit dieser Einstellung senden nur Komprimierungsanfragen von {sources} den vollständigen Gesprächsinhalt zur Zusammenfassung an die Combo {combo}; andere Gespräche nutzen weiterhin ihr eigenes Modell. Die Combo versucht ihre Ziele ({providers}) der Reihe nach und verwendet das erste, das antwortet, sodass jedes davon diese Gespräche erhalten kann.",
"compactionRouting.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.",
"compactionRouting.saved": "Komprimierungseinstellungen gespeichert.",
"compactionRouting.saveFailed": "Speichern fehlgeschlagen. Deine Änderungen sind noch vorhanden; versuche es erneut.",
Expand Down
Loading
Loading