From 0b1adb71ca687cc3b9c15cb819060b33270f5d90 Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Wed, 23 Sep 2026 11:05:22 +0800 Subject: [PATCH 1/3] feat(compaction): scope routing overrides by source model and show reasoning to the summarizer compactionRouting gained an optional sourceModels allowlist (exact selectors or provider/*; omission keeps the all-model behavior, a malformed or empty list disables the override instead of widening it), so a compaction reroute can be restricted to the conversations the operator actually wants sent to another provider for summarization. The dashboard panel gains a matching scope picker (all / selected, provider groups, scoped privacy warnings) in ten locales. Routed compaction turns now inline earlier reasoning items as blocks and the synthesized prompt tells the summarizer to mine them for rationale instead of dropping that context on the chat wire. Verified: bun run test on responses-compaction-override, responses- compaction, responses-parser and settings-stream-mode (225 pass), gui compaction-routing-panel (14 pass), tsc --noEmit clean for root and gui, file-size baseline regenerated. --- .../docs/reference/configuration/server.md | 9 ++ gui/src/components/CompactionRoutingPanel.tsx | 111 ++++++++++++++++-- gui/src/i18n/de.ts | 10 ++ gui/src/i18n/en.ts | 10 ++ gui/src/i18n/fr.ts | 10 ++ gui/src/i18n/ja.ts | 10 ++ gui/src/i18n/ko.ts | 10 ++ gui/src/i18n/ru.ts | 10 ++ gui/src/i18n/tr.ts | 10 ++ gui/src/i18n/vi.ts | 10 ++ gui/src/i18n/zh-TW.ts | 10 ++ gui/src/i18n/zh.ts | 10 ++ gui/src/pages/dashboard-overview-panels.tsx | 2 +- gui/src/styles.css | 4 + gui/tests/compaction-routing-panel.test.tsx | 100 +++++++++++++++- src/config/schema/compaction-triggers.ts | 8 ++ src/config/schema/leaf-validators.ts | 4 +- src/responses/compaction.ts | 6 +- src/responses/parser.ts | 30 ++++- src/server/responses/compaction-routing.ts | 9 +- src/types/config.ts | 2 + structure/config.md | 2 +- structure/gui-and-management-api.md | 2 + structure/transports/responses.md | 8 +- tests/config/settings-stream-mode.test.ts | 2 +- tests/fixtures/file-size-baseline.json | 48 ++++---- .../responses-compaction-override.test.ts | 73 ++++++++++++ tests/responses/responses-compaction.test.ts | 4 + tests/responses/responses-parser.test.ts | 47 ++++++++ 29 files changed, 521 insertions(+), 50 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 1d9c3064344..0ec23ec7786 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -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 diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index 6875b7ab3fa..b86e0aa8d6c 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -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"]; /** @@ -20,6 +20,16 @@ const TRIGGER_LABELS: Record = { "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 = { + "all": "compactionRouting.sourcesAll", + "selected": "compactionRouting.sourcesSelected", +}; function triggersToChoice(value: string[] | undefined): string { if (!value) return "manual"; @@ -62,6 +72,9 @@ 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"); @@ -69,19 +82,37 @@ function readSetting(payload: { compactionRouting?: unknown }): Setting { 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 { + 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 ; } -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(undefined); const [model, setModel] = useState(""); const [effort, setEffort] = useState(""); const [triggers, setTriggers] = useState("manual"); + const [scope, setScope] = useState("all"); + const [sources, setSources] = useState([]); const [busy, setBusy] = useState(false); const [loadError, setLoadError] = useState(false); const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); @@ -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); @@ -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, }), @@ -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) => ( + + ); // 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); @@ -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 (
@@ -190,27 +261,45 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
{t("compactionRouting.description")}
{t("compactionRouting.dataNotice")}
{t("compactionRouting.effortHint")}
+
{t("compactionRouting.sourcesHint")}
({ value, label: t(TRIGGER_LABELS[value]!) }))} onChange={value => { setTriggers(value); setFeedback(null); }} /> + ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]} onChange={value => { setEffort(value); setFeedback(null); }} /> -
- {model &&
{isCombo - ? t("compactionRouting.comboWarning", { combo: model, providers }) - : t("compactionRouting.providerWarning", { provider })}
} + {model && scope === "selected" &&
+ {providerWildcards.length > 0 &&
+ {t("compactionRouting.sourcesProviders")} + {providerWildcards.map(sourceRow)} +
} + {models.length > 0 &&
+ {t("compactionRouting.sourcesModels")} + {models.map(item => item.namespaced).map(sourceRow)} +
} + {savedOnlySelectors.length > 0 &&
+ {t("compactionRouting.sourcesSaved")} + {savedOnlySelectors.map(sourceRow)} +
} + {scopeEmpty &&
{t("compactionRouting.sourcesNone")}
} +
} + {warningText &&
{warningText}
} {model && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} {loadError &&
{t("compactionRouting.loadFailed")}
} {feedback === "failed" &&
{t("compactionRouting.saveFailed")}
} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0484cf8e4ed..2d6e8eeecd2 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -369,6 +369,14 @@ export const de: Record = { "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.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.", @@ -377,6 +385,8 @@ export const de: Record = { "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ea6a8f20686..e7de5bda1f7 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -387,6 +387,14 @@ export const en = { "compactionRouting.triggersManual": "Manual /compact only", "compactionRouting.triggersBoth": "Manual and automatic", "compactionRouting.triggersAuto": "Automatic only", + "compactionRouting.sources": "Sources", + "compactionRouting.sourcesAll": "All conversation models", + "compactionRouting.sourcesSelected": "Selected sources only", + "compactionRouting.sourcesProviders": "Entire providers", + "compactionRouting.sourcesModels": "Individual models", + "compactionRouting.sourcesSaved": "Saved selectors not in the current catalog", + "compactionRouting.sourcesNone": "Select at least one source, or switch back to all conversation models.", + "compactionRouting.sourcesHint": "Scope the override to compaction requests from chosen conversations. provider/* covers every model of that provider; unlisted conversations keep using their own model.", "compactionRouting.currentModel": "Use conversation model", "compactionRouting.currentEffort": "Keep request effort", "compactionRouting.effortHint": "Reasoning applies where supported by the compaction endpoint. The model must accept the full conversation.", @@ -395,6 +403,8 @@ export const en = { "compactionRouting.providerWarning": "With this setting, every covered compaction request sends the full conversation contents to {provider} for summarization.", "compactionRouting.comboWarning": "With this setting, every covered compaction request sends the full conversation contents to combo {combo} for summarization. The combo attempts its targets ({providers}) in order and uses the first that answers, so any one of them can receive the conversation.", "compactionRouting.comboProvidersUnknown": "its configured target providers", + "compactionRouting.providerWarningScoped": "With this setting, only compaction requests from {sources} send the full conversation contents to {provider} for summarization; other conversations keep their own model.", + "compactionRouting.comboWarningScoped": "With this setting, only compaction requests from {sources} send the full conversation contents to combo {combo} for summarization; other conversations keep their own model. The combo attempts its targets ({providers}) in order and uses the first that answers, so any one of them can receive those conversations.", "compactionRouting.loadFailed": "Could not load compaction settings.", "compactionRouting.saved": "Compaction settings saved.", "compactionRouting.saveFailed": "Could not save. Your changes are still here; try again.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 10c2d6293cd..6e025f8960c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -377,6 +377,14 @@ export const fr: Record = { "compactionRouting.triggersManual": "/compact manuel uniquement", "compactionRouting.triggersBoth": "Manuelle et automatique", "compactionRouting.triggersAuto": "Automatique uniquement", + "compactionRouting.sources": "Sources concernées", + "compactionRouting.sourcesAll": "Tous les modèles de conversation", + "compactionRouting.sourcesSelected": "Sources sélectionnées uniquement", + "compactionRouting.sourcesProviders": "Fournisseurs entiers", + "compactionRouting.sourcesModels": "Modèles individuels", + "compactionRouting.sourcesSaved": "Sélecteurs enregistrés absents du catalogue actuel", + "compactionRouting.sourcesNone": "Sélectionnez au moins une source, ou revenez à tous les modèles de conversation.", + "compactionRouting.sourcesHint": "Limite le remplacement aux requêtes de compaction des conversations choisies. provider/* couvre tous les modèles de ce fournisseur ; les conversations non listées gardent leur propre modèle.", "compactionRouting.currentModel": "Utiliser le modèle de la conversation", "compactionRouting.currentEffort": "Conserver l’effort de la requête", "compactionRouting.effortHint": "Le raisonnement s’applique si le point de terminaison de compaction le prend en charge. Le modèle doit accepter toute la conversation.", @@ -385,6 +393,8 @@ export const fr: Record = { "compactionRouting.providerWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation à {provider} pour la résumer.", "compactionRouting.comboWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation au combo {combo} pour le résumer. Le combo essaie ses cibles ({providers}) dans l’ordre et retient la première qui répond, donc n’importe laquelle peut recevoir la conversation.", "compactionRouting.comboProvidersUnknown": "ses fournisseurs cibles configurés", + "compactionRouting.providerWarningScoped": "Avec ce réglage, seules les requêtes de compaction issues de {sources} envoient l’intégralité de la conversation à {provider} pour le résumer ; les autres conversations gardent leur propre modèle.", + "compactionRouting.comboWarningScoped": "Avec ce réglage, seules les requêtes de compaction issues de {sources} envoient l’intégralité de la conversation au combo {combo} pour le résumer ; les autres conversations gardent leur propre modèle. Le combo essaie ses cibles ({providers}) dans l’ordre et retient la première qui répond, donc n’importe laquelle peut recevoir ces conversations.", "compactionRouting.loadFailed": "Impossible de charger les paramètres de compaction.", "compactionRouting.saved": "Paramètres de compaction enregistrés.", "compactionRouting.saveFailed": "Échec de l’enregistrement. Vos modifications sont conservées ; réessayez.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 1c8eeaf5e92..5417b854c9f 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -378,6 +378,14 @@ export const ja: Record = { "compactionRouting.triggersManual": "手動の /compact のみ", "compactionRouting.triggersBoth": "手動と自動", "compactionRouting.triggersAuto": "自動のみ", + "compactionRouting.sources": "対象ソース", + "compactionRouting.sourcesAll": "すべての会話モデル", + "compactionRouting.sourcesSelected": "選択したソースのみ", + "compactionRouting.sourcesProviders": "プロバイダー全体", + "compactionRouting.sourcesModels": "個別モデル", + "compactionRouting.sourcesSaved": "現在のカタログにない保存済みセレクター", + "compactionRouting.sourcesNone": "ソースを少なくとも 1 つ選択するか、すべての会話モデルに戻してください。", + "compactionRouting.sourcesHint": "上書きを選択した会話からの圧縮リクエストに限定します。provider/* はそのプロバイダーの全モデルを対象にし、リストにない会話は従来どおり自身のモデルで圧縮します。", "compactionRouting.currentModel": "会話のモデルを使用", "compactionRouting.currentEffort": "リクエストの推論強度を維持", "compactionRouting.effortHint": "圧縮エンドポイントが対応している場合に推論設定が適用されます。モデルは会話全体を受け入れられる必要があります。", @@ -386,6 +394,8 @@ export const ja: Record = { "compactionRouting.providerWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のために {provider} へ送信されます。", "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもが会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", + "compactionRouting.providerWarningScoped": "この設定では、{sources} からの圧縮リクエストのみが会話の全内容を要約のため {provider} へ送信し、それ以外の会話は従来どおり自身のモデルで圧縮されます。", + "compactionRouting.comboWarningScoped": "この設定では、{sources} からの圧縮リクエストのみが会話の全内容を要約のためコンボ {combo} へ送信し、それ以外の会話は従来どおり自身のモデルで圧縮されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもがこれらの会話を受け取る可能性があります。", "compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。", "compactionRouting.saved": "圧縮設定を保存しました。", "compactionRouting.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5ae1bc398ca..a5b9dfce5ed 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -373,6 +373,14 @@ export const ko: Record = { "compactionRouting.triggersManual": "수동 /compact만", "compactionRouting.triggersBoth": "수동과 자동", "compactionRouting.triggersAuto": "자동만", + "compactionRouting.sources": "소스 범위", + "compactionRouting.sourcesAll": "모든 대화 모델", + "compactionRouting.sourcesSelected": "선택한 소스만", + "compactionRouting.sourcesProviders": "전체 제공자", + "compactionRouting.sourcesModels": "개별 모델", + "compactionRouting.sourcesSaved": "현재 카탈로그에 없는 저장된 선택자", + "compactionRouting.sourcesNone": "소스를 하나 이상 선택하거나 모든 대화 모델로 되돌리세요.", + "compactionRouting.sourcesHint": "재정의를 선택한 대화의 압축 요청으로 제한합니다. provider/*는 해당 제공자의 모든 모델을 포함하며, 목록에 없는 대화는 계속 자체 모델로 압축합니다.", "compactionRouting.currentModel": "대화 모델 사용", "compactionRouting.currentEffort": "요청의 추론 수준 유지", "compactionRouting.effortHint": "압축 엔드포인트가 지원하는 경우 추론 설정이 적용됩니다. 모델은 전체 대화를 수용할 수 있어야 합니다.", @@ -381,6 +389,8 @@ export const ko: Record = { "compactionRouting.providerWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 대화를 받을 수 있습니다.", "compactionRouting.comboProvidersUnknown": "구성된 대상 프로바이더", + "compactionRouting.providerWarningScoped": "이 설정을 사용하면 {sources}의 압축 요청만 전체 대화 내용을 요약을 위해 {provider}로 전송하며, 다른 대화는 계속 자체 모델로 압축합니다.", + "compactionRouting.comboWarningScoped": "이 설정을 사용하면 {sources}의 압축 요청만 전체 대화 내용을 요약을 위해 콤보 {combo}로 전송하며, 다른 대화는 계속 자체 모델로 압축합니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 이 대화를 받을 수 있습니다.", "compactionRouting.loadFailed": "압축 설정을 불러올 수 없습니다.", "compactionRouting.saved": "압축 설정을 저장했습니다.", "compactionRouting.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 2c2a5f7a55c..94e4a13d2b5 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -378,6 +378,14 @@ export const ru: Record = { "compactionRouting.triggersManual": "Только ручной /compact", "compactionRouting.triggersBoth": "Ручное и автоматическое", "compactionRouting.triggersAuto": "Только автоматическое", + "compactionRouting.sources": "Источники", + "compactionRouting.sourcesAll": "Все модели диалога", + "compactionRouting.sourcesSelected": "Только выбранные источники", + "compactionRouting.sourcesProviders": "Провайдеры целиком", + "compactionRouting.sourcesModels": "Отдельные модели", + "compactionRouting.sourcesSaved": "Сохранённые селекторы вне текущего каталога", + "compactionRouting.sourcesNone": "Выберите хотя бы один источник или вернитесь ко всем моделям диалога.", + "compactionRouting.sourcesHint": "Ограничивает переопределение запросами сжатия из выбранных диалогов. provider/* охватывает все модели этого провайдера; остальные диалоги по-прежнему используют собственную модель.", "compactionRouting.currentModel": "Использовать модель разговора", "compactionRouting.currentEffort": "Сохранить уровень из запроса", "compactionRouting.effortHint": "Уровень рассуждений применяется, если его поддерживает конечная точка сжатия. Модель должна вмещать весь разговор.", @@ -386,6 +394,8 @@ export const ru: Record = { "compactionRouting.providerWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо пробует свои цели ({providers}) по порядку и использует первую ответившую, поэтому разговор может получить любая из них.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", + "compactionRouting.providerWarningScoped": "При этой настройке только запросы сжатия из {sources} отправляют полное содержимое диалога в {provider} для составления сводки; остальные диалоги по-прежнему используют собственную модель.", + "compactionRouting.comboWarningScoped": "При этой настройке только запросы сжатия из {sources} отправляют полное содержимое диалога комбо {combo} для составления сводки; остальные диалоги по-прежнему используют собственную модель. Комбо перебирает цели ({providers}) по порядку и использует первую ответившую, поэтому любая из них может получить эти диалоги.", "compactionRouting.loadFailed": "Не удалось загрузить настройки сжатия.", "compactionRouting.saved": "Настройки сжатия сохранены.", "compactionRouting.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 63298d07148..ecef90a3a1c 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -379,6 +379,14 @@ export const tr: Record = { "compactionRouting.triggersManual": "Yalnızca manuel /compact", "compactionRouting.triggersBoth": "Manuel ve otomatik", "compactionRouting.triggersAuto": "Yalnızca otomatik", + "compactionRouting.sources": "Kaynaklar", + "compactionRouting.sourcesAll": "Tüm konuşma modelleri", + "compactionRouting.sourcesSelected": "Yalnızca seçili kaynaklar", + "compactionRouting.sourcesProviders": "Tüm sağlayıcılar", + "compactionRouting.sourcesModels": "Tekil modeller", + "compactionRouting.sourcesSaved": "Geçerli katalogda olmayan kayıtlı seçiciler", + "compactionRouting.sourcesNone": "En az bir kaynak seçin veya tüm konuşma modellerine dönün.", + "compactionRouting.sourcesHint": "Geçersiz kılmayı seçilen konuşmaların sıkıştırma istekleriyle sınırlar. provider/* o sağlayıcının tüm modellerini kapsar; listelenmeyen konuşmalar kendi modelini kullanmaya devam eder.", "compactionRouting.currentModel": "Konuşma modelini kullan", "compactionRouting.currentEffort": "İsteğin düzeyini koru", "compactionRouting.effortHint": "Akıl yürütme, özetleme uç noktası destekliyorsa uygulanır. Model tüm konuşmayı kabul edebilmelidir.", @@ -387,6 +395,8 @@ export const tr: Record = { "compactionRouting.providerWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {provider} sağlayıcısına gönderir.", "compactionRouting.comboWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {combo} kombosuna gönderir. Kombo hedeflerini ({providers}) sırayla dener ve yanıt veren ilkini kullanır; dolayısıyla bunlardan herhangi biri konuşmayı alabilir.", "compactionRouting.comboProvidersUnknown": "yapılandırılmış hedef sağlayıcıları", + "compactionRouting.providerWarningScoped": "Bu ayarla yalnızca {sources} kaynaklı sıkıştırma istekleri konuşmanın tüm içeriğini özet için {provider} hedefine gönderir; diğer konuşmalar kendi modelini kullanmaya devam eder.", + "compactionRouting.comboWarningScoped": "Bu ayarla yalnızca {sources} kaynaklı sıkıştırma istekleri konuşmanın tüm içeriğini özet için {combo} kombosuna gönderir; diğer konuşmalar kendi modelini kullanmaya devam eder. Kombo hedeflerini ({providers}) sırayla dener ve yanıt veren ilkini kullanır; dolayısıyla bunlardan herhangi biri bu konuşmaları alabilir.", "compactionRouting.loadFailed": "Özetleme ayarları yüklenemedi.", "compactionRouting.saved": "Özetleme ayarları kaydedildi.", "compactionRouting.saveFailed": "Kaydedilemedi. Değişiklikleriniz korunuyor; tekrar deneyin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index c829e9af8a9..d75b7f13da7 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -377,6 +377,14 @@ export const vi: Record = { "compactionRouting.triggersManual": "Chỉ /compact thủ công", "compactionRouting.triggersBoth": "Thủ công và tự động", "compactionRouting.triggersAuto": "Chỉ tự động", + "compactionRouting.sources": "Nguồn áp dụng", + "compactionRouting.sourcesAll": "Tất cả model hội thoại", + "compactionRouting.sourcesSelected": "Chỉ nguồn đã chọn", + "compactionRouting.sourcesProviders": "Toàn bộ nhà cung cấp", + "compactionRouting.sourcesModels": "Từng model", + "compactionRouting.sourcesSaved": "Bộ chọn đã lưu không có trong danh mục hiện tại", + "compactionRouting.sourcesNone": "Chọn ít nhất một nguồn, hoặc quay lại tất cả model hội thoại.", + "compactionRouting.sourcesHint": "Giới hạn ghi đè chỉ cho các yêu cầu nén từ những hội thoại đã chọn. provider/* bao gồm mọi model của nhà cung cấp đó; hội thoại không được liệt kê vẫn nén bằng model của chính nó.", "compactionRouting.currentModel": "Dùng model của cuộc trò chuyện", "compactionRouting.currentEffort": "Giữ mức suy luận của yêu cầu", "compactionRouting.effortHint": "Mức suy luận được áp dụng nếu điểm cuối nén hỗ trợ. Model phải tiếp nhận được toàn bộ cuộc trò chuyện.", @@ -385,6 +393,8 @@ export const vi: Record = { "compactionRouting.providerWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến {provider} để tóm tắt.", "compactionRouting.comboWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến combo {combo} để tóm tắt. Combo thử lần lượt các đích ({providers}) và dùng đích trả lời đầu tiên, nên bất kỳ đích nào cũng có thể nhận cuộc trò chuyện.", "compactionRouting.comboProvidersUnknown": "các nhà cung cấp đích đã cấu hình của nó", + "compactionRouting.providerWarningScoped": "Với thiết lập này, chỉ các yêu cầu nén từ {sources} mới gửi toàn bộ nội dung cuộc trò chuyện đến {provider} để tóm tắt; các cuộc trò chuyện khác vẫn nén bằng model của chính nó.", + "compactionRouting.comboWarningScoped": "Với thiết lập này, chỉ các yêu cầu nén từ {sources} mới gửi toàn bộ nội dung cuộc trò chuyện đến combo {combo} để tóm tắt; các cuộc trò chuyện khác vẫn nén bằng model của chính nó. Combo thử lần lượt các đích ({providers}) và dùng đích trả lời đầu tiên, nên bất kỳ đích nào cũng có thể nhận những cuộc trò chuyện đó.", "compactionRouting.loadFailed": "Không thể tải cài đặt nén.", "compactionRouting.saved": "Đã lưu cài đặt nén.", "compactionRouting.saveFailed": "Không thể lưu. Thay đổi của bạn vẫn còn; hãy thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 2b6a11d2523..6f59fdbc488 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -268,6 +268,14 @@ export const zhTW: Record = { "compactionRouting.triggersManual": "僅手動 /compact", "compactionRouting.triggersBoth": "手動與自動", "compactionRouting.triggersAuto": "僅自動", + "compactionRouting.sources": "來源範圍", + "compactionRouting.sourcesAll": "所有對話模型", + "compactionRouting.sourcesSelected": "僅選中的來源", + "compactionRouting.sourcesProviders": "整個提供商", + "compactionRouting.sourcesModels": "單個模型", + "compactionRouting.sourcesSaved": "已保存但不在目前目錄中的選取器", + "compactionRouting.sourcesNone": "至少選擇一個來源,或改回所有對話模型。", + "compactionRouting.sourcesHint": "將覆寫限定為來自所選對話的壓縮請求。provider/* 覆蓋該提供商的全部模型;未列出的對話仍使用自身模型壓縮。", "compactionRouting.currentModel": "使用對話模型", "compactionRouting.currentEffort": "保留請求的推理強度", "compactionRouting.effortHint": "推理設定僅在壓縮端點支援時生效。模型必須能容納完整對話。", @@ -276,6 +284,8 @@ export const zhTW: Record = { "compactionRouting.providerWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給 {provider} 進行摘要。", "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到該對話。", "compactionRouting.comboProvidersUnknown": "其已設定的目標供應商", + "compactionRouting.providerWarningScoped": "啟用此設定後,僅來自 {sources} 的壓縮請求會將完整對話內容傳送給 {provider} 進行摘要;其他對話仍使用自身模型壓縮。", + "compactionRouting.comboWarningScoped": "啟用此設定後,僅來自 {sources} 的壓縮請求會將完整對話內容傳送給組合 {combo} 進行摘要;其他對話仍使用自身模型壓縮。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到這些對話。", "compactionRouting.loadFailed": "無法載入壓縮設定。", "compactionRouting.saved": "壓縮設定已儲存。", "compactionRouting.saveFailed": "儲存失敗。變更仍然保留,請重試。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index e6ce7388fdd..23e844ef9b8 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -373,6 +373,14 @@ export const zh: Record = { "compactionRouting.triggersManual": "仅手动 /compact", "compactionRouting.triggersBoth": "手动和自动", "compactionRouting.triggersAuto": "仅自动", + "compactionRouting.sources": "来源范围", + "compactionRouting.sourcesAll": "所有对话模型", + "compactionRouting.sourcesSelected": "仅选中的来源", + "compactionRouting.sourcesProviders": "整个提供商", + "compactionRouting.sourcesModels": "单个模型", + "compactionRouting.sourcesSaved": "已保存但不在当前目录中的选择器", + "compactionRouting.sourcesNone": "至少选择一个来源,或改回所有对话模型。", + "compactionRouting.sourcesHint": "将覆盖限定为来自所选对话的压缩请求。provider/* 覆盖该提供商的全部模型;未列出的对话仍使用自身模型压缩。", "compactionRouting.currentModel": "使用对话模型", "compactionRouting.currentEffort": "保留请求的推理强度", "compactionRouting.effortHint": "推理设置仅在压缩端点支持时生效。模型必须能容纳完整对话。", @@ -381,6 +389,8 @@ export const zh: Record = { "compactionRouting.providerWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给 {provider} 进行摘要。", "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到该对话。", "compactionRouting.comboProvidersUnknown": "其已配置的目标提供商", + "compactionRouting.providerWarningScoped": "启用此设置后,仅来自 {sources} 的压缩请求会将完整对话内容发送给 {provider} 进行摘要;其他对话仍使用自身模型压缩。", + "compactionRouting.comboWarningScoped": "启用此设置后,仅来自 {sources} 的压缩请求会将完整对话内容发送给组合 {combo} 进行摘要;其他对话仍使用自身模型压缩。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到这些对话。", "compactionRouting.loadFailed": "无法加载压缩设置。", "compactionRouting.saved": "压缩设置已保存。", "compactionRouting.saveFailed": "保存失败。更改仍然保留,请重试。", diff --git a/gui/src/pages/dashboard-overview-panels.tsx b/gui/src/pages/dashboard-overview-panels.tsx index 9d6a9e43684..1ca0d43bdd3 100644 --- a/gui/src/pages/dashboard-overview-panels.tsx +++ b/gui/src/pages/dashboard-overview-panels.tsx @@ -19,7 +19,7 @@ export function DashboardOverviewPanels(props: Dash) { - + ); diff --git a/gui/src/styles.css b/gui/src/styles.css index 9bdd757f6ae..45f16306a7e 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2956,3 +2956,7 @@ button.prov-account-row.active { cursor: default; } } .claude-effort-badge.on { background: color-mix(in srgb, var(--green) 15%, transparent); color: var(--green); } .claude-effort-badge.off { background: color-mix(in srgb, var(--muted) 12%, transparent); color: var(--muted); } +.source-scope-groups { display: flex; flex-wrap: wrap; gap: 8px 24px; margin-top: 12px; max-height: 220px; overflow: auto; } +.source-scope-group { border: 0; padding: 0; margin: 0; min-width: 12rem; } +.source-scope-group legend { padding: 0; } +.source-scope-group label { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; padding: 2px 0; } diff --git a/gui/tests/compaction-routing-panel.test.tsx b/gui/tests/compaction-routing-panel.test.tsx index 13fb2cde8d0..2e9c091d68b 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -11,7 +11,7 @@ let previous: Record; let win: Window; let root: Root | undefined; let container: HTMLDivElement; -let setting: { model: string; reasoningEffort?: string; triggers?: string[] } | null; +let setting: { model: string; reasoningEffort?: string; triggers?: string[]; sourceModels?: string[] } | null; let failLoad: boolean; let failSave: boolean; let combosUnavailable: boolean; @@ -58,13 +58,13 @@ afterEach(async () => { }); async function flush() { await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); }); } -async function render(base = "") { +async function render(base = "", providers: Array<{ name: string; baseUrl: string }> = []) { if (!root) { container = win.document.createElement("div") as unknown as HTMLDivElement; win.document.body.appendChild(container); root = (await import("react-dom/client")).createRoot(container); } - await act(async () => { root!.render(); }); + await act(async () => { root!.render(); }); await flush(); } async function choose(id: string, label: string) { @@ -118,6 +118,100 @@ test("the trigger selection round-trips and discloses automatic compaction", asy expect(container.querySelector('#compaction-routing-triggers')!.disabled).toBe(true); }); +test("saving an effort change preserves the configured source-model boundary", async () => { + setting = { model: "gateway/cheap", sourceModels: ["kimi/*", "google-antigravity/*"] }; + await render(); + await choose("effort", "Low"); + await save(); + expect(writes.at(-1)).toEqual({ compactionRouting: { + model: "gateway/cheap", reasoningEffort: "low", sourceModels: ["kimi/*", "google-antigravity/*"], + } }); + expect(container.textContent).toContain("kimi/*"); +}); + +function sourceCheckbox(label: string) { + const row = [...container.querySelectorAll("label")].find(item => item.textContent === label); + return row?.querySelector("input"); +} + +test("the source scope checklist writes sourceModels and round-trips", async () => { + await render(); + expect(container.querySelector("#compaction-routing-sources")!.disabled).toBe(true); + await choose("model", "gateway/cheap"); + expect(container.querySelector("#compaction-routing-sources")?.textContent).toContain("All conversation models"); + expect(container.querySelector('input[type="checkbox"]')).toBeNull(); + await choose("sources", "Selected sources only"); + // An empty selection would be rejected by the config schema, so saving stays disabled. + expect(saveButton().disabled).toBe(true); + expect(container.textContent).toContain("Select at least one source"); + await act(async () => { sourceCheckbox("gateway/*")!.click(); }); + await act(async () => { sourceCheckbox("combo/compact")!.click(); }); + expect(saveButton().disabled).toBe(false); + await save(); + expect(writes.at(-1)).toEqual({ compactionRouting: { model: "gateway/cheap", sourceModels: ["gateway/*", "combo/compact"] } }); + await render("/reloaded"); + expect(container.querySelector("#compaction-routing-sources")?.textContent).toContain("Selected sources only"); + expect(sourceCheckbox("gateway/*")!.checked).toBe(true); + expect(sourceCheckbox("combo/compact")!.checked).toBe(true); + expect(sourceCheckbox("gateway/cheap")!.checked).toBe(false); + await act(async () => { sourceCheckbox("combo/compact")!.click(); }); + await save(); + expect(writes.at(-1)).toEqual({ compactionRouting: { model: "gateway/cheap", sourceModels: ["gateway/*"] } }); + await choose("sources", "All conversation models"); + await save(); + expect(writes.at(-1)).toEqual({ compactionRouting: { model: "gateway/cheap" } }); +}); + +test("saved selectors outside the catalog stay visible and can be dropped deliberately", async () => { + setting = { model: "gateway/cheap", sourceModels: ["kimi/*", "gateway/cheap"] }; + await render(); + expect(container.querySelector("#compaction-routing-sources")?.textContent).toContain("Selected sources only"); + expect(sourceCheckbox("kimi/*")!.checked).toBe(true); + expect(sourceCheckbox("gateway/cheap")!.checked).toBe(true); + expect(saveButton().disabled).toBe(true); + await act(async () => { sourceCheckbox("kimi/*")!.click(); }); + expect(saveButton().disabled).toBe(false); + await save(); + expect(writes.at(-1)).toEqual({ compactionRouting: { model: "gateway/cheap", sourceModels: ["gateway/cheap"] } }); +}); + +test("the disclosure names the selected sources instead of claiming every request", async () => { + await render(); + await choose("model", "gateway/cheap"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("every covered compaction request"); + await choose("sources", "Selected sources only"); + // Nothing is covered while the selection is empty, so no destination claim is shown. + expect(container.querySelector('[role="note"]')).toBeNull(); + await act(async () => { sourceCheckbox("gateway/*")!.click(); }); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("compaction requests from gateway/*"); + expect(note).toContain("to gateway for summarization"); + expect(note).not.toContain("every covered compaction request"); + await choose("model", "combo/compact"); + const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(comboNote).toContain("compaction requests from gateway/*"); + expect(comboNote).toContain("combo combo/compact"); +}); + +test("the disclosure names the provider endpoint host when the provider is known", async () => { + await render("", [{ name: "gateway", baseUrl: "https://gw.example.com/v1" }, { name: "openai-apikey", baseUrl: "https://api.openai.com/v1" }]); + await choose("model", "gateway/cheap"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("to gateway (gw.example.com) for summarization"); + // A scoped override keeps the endpoint next to the sources it covers. + await choose("sources", "Selected sources only"); + await act(async () => { sourceCheckbox("gateway/*")!.click(); }); + expect(container.querySelector('[role="note"]')?.textContent).toContain("from gateway/* send the full conversation contents to gateway (gw.example.com)"); + // Combo targets carry their own endpoints, so "any of them" is checkable against the list. + await choose("model", "combo/compact"); + const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(comboNote).toContain("gateway (gw.example.com)"); + expect(comboNote).toContain("openai-apikey (api.openai.com)"); + // A base URL that fails to parse is shown raw rather than dropped. + await render("/reloaded", [{ name: "gateway", baseUrl: "not a url" }]); + await choose("model", "gateway/cheap"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("gateway (not a url)"); +}); + test("failed save retains the draft and allows retry", async () => { await render(); await choose("model", "gateway/cheap"); diff --git a/src/config/schema/compaction-triggers.ts b/src/config/schema/compaction-triggers.ts index cac2911ce71..fd5664b2bd5 100644 --- a/src/config/schema/compaction-triggers.ts +++ b/src/config/schema/compaction-triggers.ts @@ -9,3 +9,11 @@ * with "Cannot access 'runtimeRoleSchema' before initialization". */ export const COMPACTION_TRIGGERS = ["manual", "auto"] as const; + +/** Exact selectors or a provider-qualified trailing wildcard; never a global wildcard. */ +export function validCompactionSourceModels(value: unknown): value is string[] { + return Array.isArray(value) && value.length > 0 && new Set(value).size === value.length + && value.every(entry => typeof entry === "string" && entry.length > 0 + && entry === entry.trim() && !/\s/.test(entry) + && (!entry.includes("*") || /^[^/*]+\/\*$/.test(entry))); +} diff --git a/src/config/schema/leaf-validators.ts b/src/config/schema/leaf-validators.ts index 761bc512498..e1879f06493 100644 --- a/src/config/schema/leaf-validators.ts +++ b/src/config/schema/leaf-validators.ts @@ -40,7 +40,7 @@ import { isHostedToolUnsupportedForModel, } from "../../responses/hosted-tool-policy"; import { getConfigDir } from "../paths"; -import { COMPACTION_TRIGGERS } from "./compaction-triggers"; +import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "./compaction-triggers"; /** One definition of "usable secret", shared by the schema and the warnings. */ export function isUsableApiKeySecret(value: unknown): value is string { @@ -49,6 +49,8 @@ export function isUsableApiKeySecret(value: unknown): value is string { export const compactionRoutingSchema = z.object({ model: z.string().trim().min(1), + sourceModels: z.array(z.string()).refine(validCompactionSourceModels, + "sourceModels requires unique exact selectors or provider/* patterns").optional(), reasoningEffort: z.string().refine(value => pinnedReasoningEffortConfigError(value) === null).optional(), triggers: z.array(z.enum(COMPACTION_TRIGGERS)).nonempty() .refine(values => new Set(values).size === values.length, "triggers must not repeat a value") diff --git a/src/responses/compaction.ts b/src/responses/compaction.ts index b067e7c8587..6ffb3a1ad78 100644 --- a/src/responses/compaction.ts +++ b/src/responses/compaction.ts @@ -21,7 +21,9 @@ export const OCX_NATIVE_REPLAY_RECOVERY_NOTE = "Threads compacted through a routed provider can contain OpenCodeX-owned ocx1 state. " + "Before resuming one through native Codex, run `ocx recover-history --ocx-compaction --yes`."; -/** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction). */ +/** Mirrors codex-rs core/templates/compact/prompt.md (the local-compaction instruction), plus an + * ocx extension: routed compaction inlines reasoning as blocks (parser.ts), + * so the prompt tells the summarizer to mine them for rationale instead of ignoring the markup. */ export const COMPACT_PROMPT = `You are performing a CONTEXT CHECKPOINT COMPACTION. Create a handoff summary for another LLM that will resume the task. Include: @@ -30,6 +32,8 @@ Include: - What remains to be done (clear next steps) - Any critical data, examples, or references needed to continue +The conversation may include the assistant's reasoning traces inside ... blocks. Use them to capture the rationale behind key decisions, alternatives that were rejected, and open questions — summarize what they conclude, do not copy them verbatim. + Be concise, structured, and focused on helping the next LLM seamlessly continue the work.`; /** Mirrors codex-rs core/templates/compact/summary_prefix.md (framing for a replayed summary). */ diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 51bb36f5245..25932bf4185 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -75,7 +75,7 @@ function findToolById(messages: OcxMessage[], callId: string): { name: string; n function attachPendingReasoningToCallOwner( messages: OcxMessage[], callId: string, - pendingReasoning: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }>, + pendingReasoning: Array<{ part: OcxThinkingContent | OcxTextContent; envelopeSigned: boolean }>, ): void { if (pendingReasoning.length === 0 || !callId) return; for (let i = messages.length - 1; i >= 0; i--) { @@ -114,7 +114,7 @@ export function parseRequest( const systemPrompt: string[] = []; // Responses reasoning siblings belong to the following assistant, including across call items. // Keep them off the message list until that assistant arrives; turn boundaries clear the array. - const pendingReasoning: Array<{ part: OcxThinkingContent; envelopeSigned: boolean }> = []; + const pendingReasoning: Array<{ part: OcxThinkingContent | OcxTextContent; envelopeSigned: boolean }> = []; // Assistant placeholder that first folds any pending reasoning into the same turn (official // grok-build preserves reasoning across call items; Anthropic replay requires thinking to // precede tool_use inside one assistant message). @@ -127,7 +127,9 @@ export function parseRequest( return holder; }; const preservePendingReplay = () => { - const replay = pendingReasoning.filter(entry => entry.envelopeSigned || entry.part.redacted?.length); + const replay = pendingReasoning.filter( + entry => entry.envelopeSigned || (entry.part.type === "thinking" && entry.part.redacted?.length), + ); if (replay.length > 0) { ensureAssistantPlaceholder(messages, data.model, now).content.push(...replay.map(entry => entry.part)); } @@ -137,7 +139,10 @@ export function parseRequest( const loadedToolSpecs: unknown[] = []; // Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server. - let compactionRequest = false; + // Scanned upfront, not when the tail item is reached: reasoning items EARLIER in the input must + // know this turn only exists to summarize them (see the reasoning branch below). + let compactionRequest = Array.isArray(data.input) + && data.input.some(item => isObj(item) && (item as { type?: unknown }).type === "compaction_trigger"); let contextCompactionBoundary = false; let continuationConversationMessageIndex: number | undefined; @@ -297,6 +302,20 @@ export function parseRequest( continue; } + // Compaction turns discard the history once it is summarized, so replay fidelity is + // moot — and routed adapters silently drop unsigned thinking parts, which would hide + // every routed model's reasoning from the summarizer. Render it as visible text so the + // summary keeps the *why* behind decisions, not just the *what*. + if (compactionRequest && thinkingText.length > 0) { + const text = `\n${thinkingText}\n`; + const previous = pendingReasoning[pendingReasoning.length - 1]; + if (previous && !previous.envelopeSigned && previous.part.type === "text") { + previous.part = { type: "text", text: `${previous.part.text}\n${text}` }; + } else { + pendingReasoning.push({ part: { type: "text", text }, envelopeSigned: false }); + } + continue; + } // Native/non-ocxr1 encrypted-only reasoning is opaque here. Do not create a detached // assistant turn or invent replayable plaintext/signatures from the encrypted payload. if (thinkingText.length > 0 || envelope?.sig || envelope?.red?.length) { @@ -310,7 +329,8 @@ export function parseRequest( const envelopeSigned = typeof envelope?.sig === "string"; const previous = pendingReasoning[pendingReasoning.length - 1]; - if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned && !previous.part.redacted) { + if (!envelopeSigned && !part.redacted && previous && !previous.envelopeSigned + && previous.part.type === "thinking" && !previous.part.redacted) { previous.part = { ...part, thinking: `${previous.part.thinking}\n${part.thinking}`, diff --git a/src/server/responses/compaction-routing.ts b/src/server/responses/compaction-routing.ts index 4660d636a51..543fa274cb4 100644 --- a/src/server/responses/compaction-routing.ts +++ b/src/server/responses/compaction-routing.ts @@ -1,6 +1,6 @@ import type { OcxConfig } from "../../types"; import { isDeclaredReasoningEffort } from "../../reasoning-effort"; -import { COMPACTION_TRIGGERS } from "../../config/schema/compaction-triggers"; +import { COMPACTION_TRIGGERS, validCompactionSourceModels } from "../../config/schema/compaction-triggers"; import { routeConcreteModel, type RouteResult } from "../../router"; import { resolveComboId } from "../../combos/identifiers"; import { resolvePolicyProfileId } from "../../routing/profile"; @@ -57,6 +57,13 @@ export function applyCompactionRoutingOverride( && (typeof override.reasoningEffort !== "string" || !isDeclaredReasoningEffort(override.reasoningEffort))) return null; const triggers = configuredTriggers(override.triggers); if (!triggers) return null; + if (override.sourceModels !== undefined) { + if (!validCompactionSourceModels(override.sourceModels)) return null; + const source = raw.model; + if (!override.sourceModels.some(selector => selector.endsWith("/*") + ? source.startsWith(selector.slice(0, -1)) && source.length > selector.length - 1 + : source === selector)) return null; + } if (options.endpoint !== "compact" && !(Array.isArray(raw.input) && raw.input.some(item => record(item)?.type === "compaction_trigger"))) return null; diff --git a/src/types/config.ts b/src/types/config.ts index 6fc6939cce8..3c67f8f734d 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -650,6 +650,8 @@ export interface OcxConfig { modelPinnedEfforts?: Record; compactionRouting?: { model: string; + /** Incoming model allowlist: exact selectors or provider/*; omitted means all models. */ + sourceModels?: string[]; reasoningEffort?: string; /** Compaction triggers this override covers; omission means `["manual"]`. */ triggers?: ("manual" | "auto")[]; diff --git a/structure/config.md b/structure/config.md index 0f709d4aa5e..ab1e1f13cf4 100644 --- a/structure/config.md +++ b/structure/config.md @@ -94,7 +94,7 @@ merge cannot turn them into a valid config while discarding the original bytes. | --- | --- | --- | | Listener | `port`, `hostname` | The listener owns the port; `runtime-port.json` reports where it actually landed. | | Routing | `defaultProvider`, `providers`, per-provider `selectedModels`, `combos` | Explicit `provider/model` wins over `defaultProvider`; combo dispatch uses the selected target's existing capability ladder and does not create a second catalog authority. | -| Compaction routing | `compactionRouting.model`, optional `compactionRouting.reasoningEffort`, optional `compactionRouting.triggers` | Explicit Codex compaction metadata whose `compaction.trigger` is one the block names activates a request-local override; `triggers` defaults to `["manual"]`. See [Responses compaction](transports/responses.md#compaction-routing-overrides). Invalid hand edits disable the block with a load warning without discarding providers; candidate writes reject invalid blocks. | +| Compaction routing | `compactionRouting.model`, optional `compactionRouting.reasoningEffort`, `compactionRouting.triggers`, `compactionRouting.sourceModels` | Explicit Codex compaction metadata whose `compaction.trigger` is one the block names activates a request-local override; `triggers` defaults to `["manual"]`. Optional source selectors restrict it to exact incoming models or `provider/*`. See [Responses compaction](transports/responses.md#compaction-routing-overrides). Invalid hand edits disable the block with a load warning without discarding providers; candidate writes reject invalid blocks. | | Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, per-provider `modelDisplayNames`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. Exact provider model display names are durable display only overlays. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | | Spend | `spend.root`, `spend.identity`, `spend.pool`, `spend.retentionDays` | Durable token ceilings for the spend-reservation ledger. Absent is the default and means observe-only accounting: spend is still journaled and nothing is refused, so observe-only and enforced servers take the same state-directory writer lease. One live process may write one directory; explicit sibling instances need separate `OPENCODEX_HOME` directories. There is no default figure for any scope — the ledger is on by default, so a shipped ceiling would refuse real traffic on upgrade against a number nobody chose. Strictly validated and positive-integer only, because 0 would read as a budget and refuse everything; a malformed section degrades to no ceiling, which is why the write path rejects it and load diagnostics report it. Resolution and application live in `src/lib/spend-reservation-ledger.ts`; see [`transports/responses.md`](transports/responses.md). | diff --git a/structure/gui-and-management-api.md b/structure/gui-and-management-api.md index c465c2c3fae..2b214c9af23 100644 --- a/structure/gui-and-management-api.md +++ b/structure/gui-and-management-api.md @@ -827,6 +827,8 @@ is chosen; for a combo selector the warning lists the combo's target providers f and states that failover targets receive the conversation too. `GET /api/settings` returns the override or null; `PUT /api/settings` accepts a complete validated object or null to clear it. Save failure restores live settings and deletion provenance; the dashboard retains the draft for retry. +The dashboard displays configured `sourceModels` as technical data and preserves that boundary +when saving model, effort, or trigger changes; edit the allowlist through config or the settings API. `src/server/gui-static.ts` serves the dashboard from `gui/dist`, with `OPENCODEX_GUI_DIST` taking priority and standalone binaries resolving the copied directory beside `ocx`. Runtime package diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 32c55bd4404..69b0799d1c4 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -1114,6 +1114,12 @@ Provider-scoped approval reviewer settings are projected by the [catalog owner]( ## Compaction routing overrides +Optional `compactionRouting.sourceModels` restricts the override to exact incoming model selectors +or provider-wide `provider/*` selectors. Omission keeps the existing all-model behavior; malformed +or empty lists disable the override. Matching is case-sensitive and precedes all model rewrites. +A bare native compaction model is not inferred to belong to a previous routed turn: scoped +overrides leave it unchanged, including when Codex chose it for a routed thread's auto compaction. + `src/server/responses/compaction-routing.ts` applies `compactionRouting` before model routing in both `request-prepare.ts` and `compact.ts`. It requires explicit `request_kind: "compaction"` in `x-codex-turn-metadata`, supplied as a header or embedded in Responses `client_metadata`, and on @@ -1158,7 +1164,7 @@ in place of its history. Identity checks remove synthetic fast/effort suffixes first. A stale selector that only resolves through the default provider cannot establish the original serving identity and stays portable. -`tests/responses/responses-compaction-override.test.ts` covers trigger selection, config validation, +`tests/responses/responses-compaction-override.test.ts` covers source filtering, trigger selection, config validation, native and routed handlers, same-provider credential retention, cross-provider portable summaries and their replay, combo failover, and subsequent conversation settings. diff --git a/tests/config/settings-stream-mode.test.ts b/tests/config/settings-stream-mode.test.ts index c614aefc9f9..fab0d2de8c1 100644 --- a/tests/config/settings-stream-mode.test.ts +++ b/tests/config/settings-stream-mode.test.ts @@ -889,7 +889,7 @@ describe("manual compaction settings", () => { expect((await (await getSettings(config))!.json()).compactionRouting).toEqual(setting); await putSettings(config, { compactionRouting: { model: "gateway/cheap" } }); expect(loadConfig().compactionRouting).toEqual({ model: "gateway/cheap" }); - const automatic = { model: "gateway/cheap", triggers: ["manual", "auto"] }; + const automatic = { model: "gateway/cheap", triggers: ["manual", "auto"], sourceModels: ["kimi/*", "google-antigravity/*"] }; expect((await putSettings(config, { compactionRouting: automatic }))?.status).toBe(200); expect(loadConfig().compactionRouting).toEqual(automatic); await putSettings(config, { compactionRouting: null }); diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index 716b586605b..609487fdf58 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -16,55 +16,55 @@ ], "files": { ".github/scripts/issue-quality.test.cjs": 2143, - "gui/src/pages/Models.tsx": 2792, + "gui/src/pages/Models.tsx": 2784, "gui/src/styles.css": 2958, - "src/adapters/openai-chat.ts": 822, + "src/adapters/openai-chat.ts": 782, "src/adapters/openai-responses.ts": 6, "src/bridge.ts": 7, "src/codex/auth-api.ts": 43, "src/codex/catalog/provider-fetch.ts": 54, "src/codex/catalog/sync.ts": 52, - "src/codex/inject.ts": 987, - "src/codex/routing.ts": 1626, + "src/codex/inject.ts": 862, + "src/codex/routing.ts": 1612, "src/codex/shim.ts": 1246, - "src/config.ts": 460, + "src/config.ts": 383, "src/providers/quota.ts": 558, "src/providers/registry.ts": 232, - "src/responses/state.ts": 1371, - "src/server/index.ts": 893, + "src/responses/state.ts": 1345, + "src/server/index.ts": 885, "src/server/responses/core.ts": 210, - "tests/ci-workflows/ci-workflows.test.ts": 5628, + "tests/ci-workflows/ci-workflows.test.ts": 5449, "tests/cli/cli-account.test.ts": 2313, - "tests/codex-integration/codex-auth-api.test.ts": 6549, - "tests/codex-integration/codex-auth-context.test.ts": 2496, - "tests/codex-integration/codex-catalog.test.ts": 7985, + "tests/codex-integration/codex-auth-api.test.ts": 6532, + "tests/codex-integration/codex-auth-context.test.ts": 2433, + "tests/codex-integration/codex-catalog.test.ts": 7974, "tests/codex-integration/codex-reset-credit-recovery.test.ts": 2135, "tests/codex-integration/codex-routing.test.ts": 3443, - "tests/codex-integration/codex-shim.test.ts": 2388, - "tests/codex-integration/codex-v2-gate.test.ts": 2111, + "tests/codex-integration/codex-shim.test.ts": 2351, + "tests/codex-integration/codex-v2-gate.test.ts": 2110, "tests/providers/cursor/cursor-blob.test.ts": 3657, "tests/providers/kiro/kiro-adapter.test.ts": 2050, "tests/providers/kiro/kiro-stream.test.ts": 2258, "tests/providers/provider-quota.test.ts": 3763, - "tests/responses/chat-completions-endpoint.test.ts": 3646, + "tests/responses/chat-completions-endpoint.test.ts": 3623, "tests/responses/openai-responses-passthrough.test.ts": 4809, - "tests/responses/responses-compaction-routing.test.ts": 2776, - "tests/responses/responses-custom-tool-repair.test.ts": 2143, - "tests/responses/responses-state.test.ts": 3983, - "tests/responses/responses-undeclared-tool-guard.test.ts": 2379, - "tests/responses/ws-upstream.test.ts": 2004, - "tests/routing/subagent-fallback-handle-responses.test.ts": 2289, + "tests/responses/responses-compaction-routing.test.ts": 2772, + "tests/responses/responses-custom-tool-repair.test.ts": 1183, + "tests/responses/responses-state.test.ts": 3982, + "tests/responses/responses-undeclared-tool-guard.test.ts": 2371, + "tests/responses/ws-upstream.test.ts": 2000, + "tests/routing/subagent-fallback-handle-responses.test.ts": 2270, "tests/server/config.test.ts": 3828, - "tests/server/management-provider-validation.test.ts": 5506, - "tests/server/server-auth.test.ts": 4589, - "tests/server/server-combo-failover-e2e.test.ts": 4166, + "tests/server/management-provider-validation.test.ts": 5498, + "tests/server/server-auth.test.ts": 4400, + "tests/server/server-combo-failover-e2e.test.ts": 4153, "tests/server/server-images.test.ts": 2755, "tests/server/server-live.test.ts": 2253, "tests/service/service.test.ts": 4106, "tests/storage/storage-cleanup.test.ts": 2303, "tests/usage/request-log.test.ts": 2075, "tests/usage/usage-summary.test.ts": 2069, - "tests/web-search/web-search.test.ts": 2823, + "tests/web-search/web-search.test.ts": 2803, "tests/windows/windows-secret-acl.test.ts": 2310 } } diff --git a/tests/responses/responses-compaction-override.test.ts b/tests/responses/responses-compaction-override.test.ts index 7e682afe1cb..619d444ad80 100644 --- a/tests/responses/responses-compaction-override.test.ts +++ b/tests/responses/responses-compaction-override.test.ts @@ -95,6 +95,64 @@ afterEach(() => { }); describe("manual compaction request selection", () => { + test("source matching is exact or provider-bounded on both endpoints and WebSocket", () => { + const settings = config(); + settings.compactionRouting!.sourceModels = ["kimi/*", "google-antigravity/*", "Company/moonshot.public.kimi-k3"]; + settings.compactionRouting!.triggers = ["manual", "auto"]; + for (const endpoint of ["responses", "compact"] as const) { + for (const transport of [undefined, "websocket"] as const) { + for (const trigger of ["manual", "auto"]) { + for (const [model, allowed] of [ + ["kimi/k3", true], ["google-antigravity/gemini-3.8-flash", true], + ["Company/moonshot.public.kimi-k3", true], ["Company/moonshot.public.kimi-k3-other", false], + ["kimi-fake/k3", false], ["kimi/", false], ["main/gpt-6-astra", false], + ["openai/gpt-6-astra", false], ["gpt-6-astra", false], ["deepseek/deepseek-flash", false], + ] as const) { + const input = { ...body(endpoint === "responses"), model, + client_metadata: { "x-codex-turn-metadata": metadata(trigger) } }; + const before = structuredClone(input); + const result = applyCompactionRoutingOverride(input, new Headers(), settings, { endpoint, transport }); + expect(Boolean(result)).toBe(allowed); + expect(input.model).toBe(allowed ? "gateway/cheap" : model); + if (!allowed) expect(input).toEqual(before); + } + } + } + } + const ordinary = { ...body(false), model: "kimi/k3" }; + expect(applyCompactionRoutingOverride(ordinary, new Headers({ "x-codex-turn-metadata": metadata() }), settings)).toBeNull(); + expect(ordinary.model).toBe("kimi/k3"); + }); + + test("source-scoped compaction keeps GPT native and only rewrites Kimi and agy", async () => { + const settings = config(); + Object.assign(settings.compactionRouting!, { + sourceModels: ["kimi/*", "google-antigravity/*"], triggers: ["manual", "auto"], + }); + settings.providers.openai = { + adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", + baseUrl: "https://chatgpt.com/backend-api/codex", + }; + const calls: Array<{ url: string; model: string }> = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + const input = JSON.parse(String(init?.body)); + calls.push({ url: String(url), model: input.model }); + return upstreamCompletion(input); + }) as typeof fetch; + for (const trigger of ["manual", "auto"]) { + for (const model of ["gpt-6-astra", "kimi/k3", "google-antigravity/gemini-3.8-flash"]) { + const req = request({ ...body(), model, stream: true }, trigger); + req.headers.set("authorization", `Bearer ${fakeChatGptJwt({ chatgpt_account_id: "fixture-account" })}`); + const response = await handleResponses(req, settings, { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + const native = model === "gpt-6-astra"; + expect(calls.at(-1)?.model).toBe(native ? "gpt-6-astra" : "cheap"); + expect(calls.at(-1)?.url).toContain(native ? "chatgpt.com" : "gateway.example"); + } + } + }); + test.each(["header", "body", "both"])("uses explicit manual metadata from %s", location => { const input = body(); const history = structuredClone(input.input); @@ -171,6 +229,21 @@ describe("manual compaction request selection", () => { describe("manual compaction config", () => { + test("source allowlists survive configuration validation and reject ambiguous patterns", () => { + const settings = config(); + const sourceModels = ["kimi/*", "google-antigravity/*", "Company/moonshot.public.kimi-k3"]; + Object.assign(settings.compactionRouting!, { sourceModels }); + expect(validateConfigCandidate(settings).ok).toBe(true); + expect(configSchema.parse(settings).compactionRouting).toEqual(settings.compactionRouting); + for (const invalid of [[], null, "kimi/*", ["*"], ["kimi*"], ["kimi/*/x"], [" kimi/*"], ["kimi/*", "kimi/*"], [12]]) { + const candidate = { ...settings, compactionRouting: { ...settings.compactionRouting, sourceModels: invalid } }; + expect(validateConfigCandidate(candidate).ok).toBe(false); + expect(configSchema.parse(candidate).compactionRouting).toBeUndefined(); + const input = body(); + expect(applyCompactionRoutingOverride(input, new Headers({ "x-codex-turn-metadata": metadata() }), candidate as OcxConfig)).toBeNull(); + } + }); + test("validates optional settings without resetting providers on malformed hand edits", () => { expect(validateConfigCandidate(config()).ok).toBe(true); for (const value of [null, {}, [], "cheap", { model: " " }, { model: 42 }, diff --git a/tests/responses/responses-compaction.test.ts b/tests/responses/responses-compaction.test.ts index edf6fec1bba..aae8a23971e 100644 --- a/tests/responses/responses-compaction.test.ts +++ b/tests/responses/responses-compaction.test.ts @@ -410,6 +410,10 @@ describe("COMPACT_PROMPT", () => { expect(COMPACT_PROMPT).toContain("CONTEXT CHECKPOINT COMPACTION"); expect(COMPACT_PROMPT).toContain("What remains to be done"); }); + + test("tells the summarizer to mine inlined reasoning traces for rationale", () => { + expect(COMPACT_PROMPT).toContain(""); + }); }); describe("forward-path ocx1 compaction scrub", () => { diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 4b6605c3169..c828ae83004 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -1203,3 +1203,50 @@ test("parser leaf seams preserve tool and format contracts without importing the expect(readFileSync(repoPath("src", "responses", leaf), "utf8")).not.toMatch(/from\s+["\x27]\.\/parser["\x27]/); } }); + +describe("compaction turns expose reasoning to the summarizer", () => { + const parse = (input: unknown[]) => parseRequest({ model: "kimi/k3-256k", input }); + const reasoningItem = (text: string) => ({ + type: "reasoning", + id: "rs_1", + content: [{ type: "reasoning_text", text }], + }); + const userMsg = { type: "message", role: "user", content: [{ type: "input_text", text: "调整压缩方案" }] }; + const assistantMsg = { type: "message", role: "assistant", content: [{ type: "output_text", text: "好的" }] }; + const contentParts = (parsed: ReturnType) => { + const assistant = parsed.context.messages.find((message) => message.role === "assistant"); + const content = assistant && "content" in assistant ? assistant.content : []; + return (Array.isArray(content) ? content : []) as { type: string; text?: string; thinking?: string }[]; + }; + + test("compaction_trigger turns render reasoning as visible text instead of thinking parts", () => { + const parsed = parse([userMsg, reasoningItem("应该保留推理"), assistantMsg, { type: "compaction_trigger" }]); + expect(parsed._compactionRequest).toBe(true); + const parts = contentParts(parsed); + expect(parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")).toContain("应该保留推理"); + expect(parts.some((p) => p.type === "thinking")).toBe(false); + }); + + test("consecutive reasoning items merge into one text block on compaction turns", () => { + const parsed = parse([ + userMsg, + reasoningItem("第一步结论"), + { type: "reasoning", id: "rs_2", content: [{ type: "reasoning_text", text: "第二步结论" }] }, + assistantMsg, + { type: "compaction_trigger" }, + ]); + const parts = contentParts(parsed); + const reasoningTexts = parts.filter((p) => p.type === "text" && (p.text ?? "").includes("assistant_reasoning")); + expect(reasoningTexts).toHaveLength(1); + expect(reasoningTexts[0]?.text).toContain("第一步结论"); + expect(reasoningTexts[0]?.text).toContain("第二步结论"); + }); + + test("normal turns keep reasoning as thinking parts", () => { + const parsed = parse([userMsg, reasoningItem("应该保留推理"), assistantMsg]); + expect(parsed._compactionRequest).toBeUndefined(); + expect(contentParts(parsed)).toEqual( + expect.arrayContaining([expect.objectContaining({ type: "thinking", thinking: "应该保留推理" })]), + ); + }); +}); From fef54e45e7adb99dc39052c0ba2d27b509194863 Mon Sep 17 00:00:00 2001 From: yuanyuan Date: Wed, 23 Sep 2026 11:58:57 +0800 Subject: [PATCH 2/3] fix(compaction): label routing controls and the source grid The four pickers rendered as an unlabeled row and the checkbox grid had no caption, so the panel did not say which model summarizes and which conversations get rerouted. Add a visible caption over each select (reusing the existing aria-label strings) and a title over the source grid, with the string in all ten locales. --- gui/src/components/CompactionRoutingPanel.tsx | 75 +++++++++++-------- gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/fr.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/vi.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/styles.css | 7 ++ gui/tests/compaction-routing-panel.test.tsx | 20 +++++ tests/fixtures/file-size-baseline.json | 50 ++++++------- 14 files changed, 107 insertions(+), 55 deletions(-) diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index b86e0aa8d6c..f1d1f235779 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -263,41 +263,56 @@ function CompactionRoutingControls({ apiBase, models, providers: providerList =
{t("compactionRouting.effortHint")}
{t("compactionRouting.sourcesHint")}
-
- ({ value, label: t(TRIGGER_LABELS[value]!) }))} - onChange={value => { setTriggers(value); setFeedback(null); }} /> - ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]} - onChange={value => { setEffort(value); setFeedback(null); }} /> +
+
+ + ({ value, label: t(TRIGGER_LABELS[value]!) }))} + onChange={value => { setTriggers(value); setFeedback(null); }} /> +
+
+ + ({ value, label: t(`models.reasoningEffort.${value}` as TKey) }))]} + onChange={value => { setEffort(value); setFeedback(null); }} /> +
- {model && scope === "selected" &&
- {providerWildcards.length > 0 &&
- {t("compactionRouting.sourcesProviders")} - {providerWildcards.map(sourceRow)} -
} - {models.length > 0 &&
- {t("compactionRouting.sourcesModels")} - {models.map(item => item.namespaced).map(sourceRow)} -
} - {savedOnlySelectors.length > 0 &&
- {t("compactionRouting.sourcesSaved")} - {savedOnlySelectors.map(sourceRow)} -
} - {scopeEmpty &&
{t("compactionRouting.sourcesNone")}
} + {model && scope === "selected" &&
+
{t("compactionRouting.sourcesGridTitle")}
+
+ {providerWildcards.length > 0 &&
+ {t("compactionRouting.sourcesProviders")} + {providerWildcards.map(sourceRow)} +
} + {models.length > 0 &&
+ {t("compactionRouting.sourcesModels")} + {models.map(item => item.namespaced).map(sourceRow)} +
} + {savedOnlySelectors.length > 0 &&
+ {t("compactionRouting.sourcesSaved")} + {savedOnlySelectors.map(sourceRow)} +
} + {scopeEmpty &&
{t("compactionRouting.sourcesNone")}
} +
} {warningText &&
{warningText}
} {model && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 2d6e8eeecd2..a402529c177 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -377,6 +377,7 @@ export const de: Record = { "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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index e7de5bda1f7..18525bc1f21 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -395,6 +395,7 @@ export const en = { "compactionRouting.sourcesSaved": "Saved selectors not in the current catalog", "compactionRouting.sourcesNone": "Select at least one source, or switch back to all conversation models.", "compactionRouting.sourcesHint": "Scope the override to compaction requests from chosen conversations. provider/* covers every model of that provider; unlisted conversations keep using their own model.", + "compactionRouting.sourcesGridTitle": "Reroute compaction requests from these sources:", "compactionRouting.currentModel": "Use conversation model", "compactionRouting.currentEffort": "Keep request effort", "compactionRouting.effortHint": "Reasoning applies where supported by the compaction endpoint. The model must accept the full conversation.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 6e025f8960c..61606ffee54 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -385,6 +385,7 @@ export const fr: Record = { "compactionRouting.sourcesSaved": "Sélecteurs enregistrés absents du catalogue actuel", "compactionRouting.sourcesNone": "Sélectionnez au moins une source, ou revenez à tous les modèles de conversation.", "compactionRouting.sourcesHint": "Limite le remplacement aux requêtes de compaction des conversations choisies. provider/* couvre tous les modèles de ce fournisseur ; les conversations non listées gardent leur propre modèle.", + "compactionRouting.sourcesGridTitle": "Rerouter les requêtes de compaction issues de ces sources :", "compactionRouting.currentModel": "Utiliser le modèle de la conversation", "compactionRouting.currentEffort": "Conserver l’effort de la requête", "compactionRouting.effortHint": "Le raisonnement s’applique si le point de terminaison de compaction le prend en charge. Le modèle doit accepter toute la conversation.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 5417b854c9f..ad6a702abce 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -386,6 +386,7 @@ export const ja: Record = { "compactionRouting.sourcesSaved": "現在のカタログにない保存済みセレクター", "compactionRouting.sourcesNone": "ソースを少なくとも 1 つ選択するか、すべての会話モデルに戻してください。", "compactionRouting.sourcesHint": "上書きを選択した会話からの圧縮リクエストに限定します。provider/* はそのプロバイダーの全モデルを対象にし、リストにない会話は従来どおり自身のモデルで圧縮します。", + "compactionRouting.sourcesGridTitle": "以下のソースからの圧縮リクエストをリルートします:", "compactionRouting.currentModel": "会話のモデルを使用", "compactionRouting.currentEffort": "リクエストの推論強度を維持", "compactionRouting.effortHint": "圧縮エンドポイントが対応している場合に推論設定が適用されます。モデルは会話全体を受け入れられる必要があります。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index a5b9dfce5ed..ebbd21a1c72 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -381,6 +381,7 @@ export const ko: Record = { "compactionRouting.sourcesSaved": "현재 카탈로그에 없는 저장된 선택자", "compactionRouting.sourcesNone": "소스를 하나 이상 선택하거나 모든 대화 모델로 되돌리세요.", "compactionRouting.sourcesHint": "재정의를 선택한 대화의 압축 요청으로 제한합니다. provider/*는 해당 제공자의 모든 모델을 포함하며, 목록에 없는 대화는 계속 자체 모델로 압축합니다.", + "compactionRouting.sourcesGridTitle": "다음 소스에서 온 압축 요청을 리라우팅합니다:", "compactionRouting.currentModel": "대화 모델 사용", "compactionRouting.currentEffort": "요청의 추론 수준 유지", "compactionRouting.effortHint": "압축 엔드포인트가 지원하는 경우 추론 설정이 적용됩니다. 모델은 전체 대화를 수용할 수 있어야 합니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 94e4a13d2b5..4b6058c4286 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -386,6 +386,7 @@ export const ru: Record = { "compactionRouting.sourcesSaved": "Сохранённые селекторы вне текущего каталога", "compactionRouting.sourcesNone": "Выберите хотя бы один источник или вернитесь ко всем моделям диалога.", "compactionRouting.sourcesHint": "Ограничивает переопределение запросами сжатия из выбранных диалогов. provider/* охватывает все модели этого провайдера; остальные диалоги по-прежнему используют собственную модель.", + "compactionRouting.sourcesGridTitle": "Перенаправлять запросы сжатия из этих источников:", "compactionRouting.currentModel": "Использовать модель разговора", "compactionRouting.currentEffort": "Сохранить уровень из запроса", "compactionRouting.effortHint": "Уровень рассуждений применяется, если его поддерживает конечная точка сжатия. Модель должна вмещать весь разговор.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index ecef90a3a1c..f97154246d1 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -387,6 +387,7 @@ export const tr: Record = { "compactionRouting.sourcesSaved": "Geçerli katalogda olmayan kayıtlı seçiciler", "compactionRouting.sourcesNone": "En az bir kaynak seçin veya tüm konuşma modellerine dönün.", "compactionRouting.sourcesHint": "Geçersiz kılmayı seçilen konuşmaların sıkıştırma istekleriyle sınırlar. provider/* o sağlayıcının tüm modellerini kapsar; listelenmeyen konuşmalar kendi modelini kullanmaya devam eder.", + "compactionRouting.sourcesGridTitle": "Bu kaynaklardan gelen sıkıştırma isteklerini yeniden yönlendir:", "compactionRouting.currentModel": "Konuşma modelini kullan", "compactionRouting.currentEffort": "İsteğin düzeyini koru", "compactionRouting.effortHint": "Akıl yürütme, özetleme uç noktası destekliyorsa uygulanır. Model tüm konuşmayı kabul edebilmelidir.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index d75b7f13da7..e6aee4a1212 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -385,6 +385,7 @@ export const vi: Record = { "compactionRouting.sourcesSaved": "Bộ chọn đã lưu không có trong danh mục hiện tại", "compactionRouting.sourcesNone": "Chọn ít nhất một nguồn, hoặc quay lại tất cả model hội thoại.", "compactionRouting.sourcesHint": "Giới hạn ghi đè chỉ cho các yêu cầu nén từ những hội thoại đã chọn. provider/* bao gồm mọi model của nhà cung cấp đó; hội thoại không được liệt kê vẫn nén bằng model của chính nó.", + "compactionRouting.sourcesGridTitle": "Định tuyến lại các yêu cầu nén từ các nguồn sau:", "compactionRouting.currentModel": "Dùng model của cuộc trò chuyện", "compactionRouting.currentEffort": "Giữ mức suy luận của yêu cầu", "compactionRouting.effortHint": "Mức suy luận được áp dụng nếu điểm cuối nén hỗ trợ. Model phải tiếp nhận được toàn bộ cuộc trò chuyện.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 6f59fdbc488..4a39148a174 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -276,6 +276,7 @@ export const zhTW: Record = { "compactionRouting.sourcesSaved": "已保存但不在目前目錄中的選取器", "compactionRouting.sourcesNone": "至少選擇一個來源,或改回所有對話模型。", "compactionRouting.sourcesHint": "將覆寫限定為來自所選對話的壓縮請求。provider/* 覆蓋該提供商的全部模型;未列出的對話仍使用自身模型壓縮。", + "compactionRouting.sourcesGridTitle": "改路由以下來源對話的壓縮請求:", "compactionRouting.currentModel": "使用對話模型", "compactionRouting.currentEffort": "保留請求的推理強度", "compactionRouting.effortHint": "推理設定僅在壓縮端點支援時生效。模型必須能容納完整對話。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 23e844ef9b8..840ba4cb445 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -381,6 +381,7 @@ export const zh: Record = { "compactionRouting.sourcesSaved": "已保存但不在当前目录中的选择器", "compactionRouting.sourcesNone": "至少选择一个来源,或改回所有对话模型。", "compactionRouting.sourcesHint": "将覆盖限定为来自所选对话的压缩请求。provider/* 覆盖该提供商的全部模型;未列出的对话仍使用自身模型压缩。", + "compactionRouting.sourcesGridTitle": "改路由以下来源对话的压缩请求:", "compactionRouting.currentModel": "使用对话模型", "compactionRouting.currentEffort": "保留请求的推理强度", "compactionRouting.effortHint": "推理设置仅在压缩端点支持时生效。模型必须能容纳完整对话。", diff --git a/gui/src/styles.css b/gui/src/styles.css index 45f16306a7e..b7b253e82f5 100644 --- a/gui/src/styles.css +++ b/gui/src/styles.css @@ -2960,3 +2960,10 @@ button.prov-account-row.active { cursor: default; } .source-scope-group { border: 0; padding: 0; margin: 0; min-width: 12rem; } .source-scope-group legend { padding: 0; } .source-scope-group label { display: flex; align-items: center; gap: 7px; color: var(--text); font-size: 12px; padding: 2px 0; } +/* Captioned controls: a visible label above each select keeps the summarizer model, the + trigger scope, the source scope, and the effort from reading as four unnamed pickers. */ +.compaction-routing-controls { align-items: flex-end; } +.compaction-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; } +.compaction-sources-block { margin-top: 12px; } +.compaction-sources-block .compaction-sources-title { margin-bottom: 6px; } +.compaction-sources-block .source-scope-groups { margin-top: 0; } diff --git a/gui/tests/compaction-routing-panel.test.tsx b/gui/tests/compaction-routing-panel.test.tsx index 2e9c091d68b..4db731caf11 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -95,6 +95,26 @@ test("saves model and optional effort, reloads, removes effort, and clears overr expect(container.querySelector('#compaction-routing-effort')!.disabled).toBe(true); }); +test("names each control and the source grid so the summarizer and the covered sources are unmistakable", async () => { + await render(); + // Visible captions, not just aria-labels: an unlabeled row of selects read as if every + // control picked the summarizer, and the checkbox grid read as candidate models. + const captions: Array<[string, string]> = [ + ["model", "Compaction model"], + ["triggers", "Applies to"], + ["sources", "Sources"], + ["effort", "Reasoning effort"], + ]; + for (const [id, text] of captions) { + const caption = [...container.querySelectorAll("label")].find(item => item.textContent === text); + expect(caption, "visible caption: " + text).toBeDefined(); + expect(caption!.getAttribute("for")).toBe("compaction-routing-" + id); + } + await choose("model", "gateway/cheap"); + await choose("sources", "Selected sources only"); + expect(container.textContent).toContain("Reroute compaction requests from these sources:"); +}); + test("the trigger selection round-trips and discloses automatic compaction", async () => { await render(); await choose("model", "gateway/cheap"); diff --git a/tests/fixtures/file-size-baseline.json b/tests/fixtures/file-size-baseline.json index 609487fdf58..6b1540cd25d 100644 --- a/tests/fixtures/file-size-baseline.json +++ b/tests/fixtures/file-size-baseline.json @@ -16,55 +16,55 @@ ], "files": { ".github/scripts/issue-quality.test.cjs": 2143, - "gui/src/pages/Models.tsx": 2784, - "gui/src/styles.css": 2958, - "src/adapters/openai-chat.ts": 782, + "gui/src/pages/Models.tsx": 2792, + "gui/src/styles.css": 2969, + "src/adapters/openai-chat.ts": 822, "src/adapters/openai-responses.ts": 6, "src/bridge.ts": 7, "src/codex/auth-api.ts": 43, "src/codex/catalog/provider-fetch.ts": 54, "src/codex/catalog/sync.ts": 52, - "src/codex/inject.ts": 862, - "src/codex/routing.ts": 1612, + "src/codex/inject.ts": 987, + "src/codex/routing.ts": 1626, "src/codex/shim.ts": 1246, - "src/config.ts": 383, + "src/config.ts": 460, "src/providers/quota.ts": 558, "src/providers/registry.ts": 232, - "src/responses/state.ts": 1345, - "src/server/index.ts": 885, + "src/responses/state.ts": 1371, + "src/server/index.ts": 893, "src/server/responses/core.ts": 210, - "tests/ci-workflows/ci-workflows.test.ts": 5449, + "tests/ci-workflows/ci-workflows.test.ts": 5628, "tests/cli/cli-account.test.ts": 2313, - "tests/codex-integration/codex-auth-api.test.ts": 6532, - "tests/codex-integration/codex-auth-context.test.ts": 2433, - "tests/codex-integration/codex-catalog.test.ts": 7974, + "tests/codex-integration/codex-auth-api.test.ts": 6549, + "tests/codex-integration/codex-auth-context.test.ts": 2496, + "tests/codex-integration/codex-catalog.test.ts": 7985, "tests/codex-integration/codex-reset-credit-recovery.test.ts": 2135, "tests/codex-integration/codex-routing.test.ts": 3443, - "tests/codex-integration/codex-shim.test.ts": 2351, - "tests/codex-integration/codex-v2-gate.test.ts": 2110, + "tests/codex-integration/codex-shim.test.ts": 2388, + "tests/codex-integration/codex-v2-gate.test.ts": 2111, "tests/providers/cursor/cursor-blob.test.ts": 3657, "tests/providers/kiro/kiro-adapter.test.ts": 2050, "tests/providers/kiro/kiro-stream.test.ts": 2258, "tests/providers/provider-quota.test.ts": 3763, - "tests/responses/chat-completions-endpoint.test.ts": 3623, + "tests/responses/chat-completions-endpoint.test.ts": 3646, "tests/responses/openai-responses-passthrough.test.ts": 4809, - "tests/responses/responses-compaction-routing.test.ts": 2772, - "tests/responses/responses-custom-tool-repair.test.ts": 1183, - "tests/responses/responses-state.test.ts": 3982, - "tests/responses/responses-undeclared-tool-guard.test.ts": 2371, - "tests/responses/ws-upstream.test.ts": 2000, - "tests/routing/subagent-fallback-handle-responses.test.ts": 2270, + "tests/responses/responses-compaction-routing.test.ts": 2776, + "tests/responses/responses-custom-tool-repair.test.ts": 2143, + "tests/responses/responses-state.test.ts": 3983, + "tests/responses/responses-undeclared-tool-guard.test.ts": 2379, + "tests/responses/ws-upstream.test.ts": 2004, + "tests/routing/subagent-fallback-handle-responses.test.ts": 2289, "tests/server/config.test.ts": 3828, - "tests/server/management-provider-validation.test.ts": 5498, - "tests/server/server-auth.test.ts": 4400, - "tests/server/server-combo-failover-e2e.test.ts": 4153, + "tests/server/management-provider-validation.test.ts": 5506, + "tests/server/server-auth.test.ts": 4589, + "tests/server/server-combo-failover-e2e.test.ts": 4166, "tests/server/server-images.test.ts": 2755, "tests/server/server-live.test.ts": 2253, "tests/service/service.test.ts": 4106, "tests/storage/storage-cleanup.test.ts": 2303, "tests/usage/request-log.test.ts": 2075, "tests/usage/usage-summary.test.ts": 2069, - "tests/web-search/web-search.test.ts": 2803, + "tests/web-search/web-search.test.ts": 2823, "tests/windows/windows-secret-acl.test.ts": 2310 } } From 3d9681ff19688c2896c1fb089f32aa6fbffde309 Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Fri, 25 Sep 2026 08:18:46 +0800 Subject: [PATCH 3/3] fix(compaction): preserve trailing reasoning and clarify source scope --- .../docs/reference/configuration/server.md | 11 +++++++---- gui/src/i18n/de.ts | 8 ++++---- gui/src/i18n/en.ts | 8 ++++---- gui/src/i18n/fr.ts | 8 ++++---- gui/src/i18n/ja.ts | 8 ++++---- gui/src/i18n/ko.ts | 8 ++++---- gui/src/i18n/ru.ts | 8 ++++---- gui/src/i18n/tr.ts | 8 ++++---- gui/src/i18n/vi.ts | 8 ++++---- gui/src/i18n/zh-TW.ts | 8 ++++---- gui/src/i18n/zh.ts | 8 ++++---- gui/tests/compaction-routing-panel.test.tsx | 8 ++++---- src/responses/parser.ts | 4 +++- structure/dashboard-and-usage.md | 6 ++++-- tests/responses/responses-parser.test.ts | 18 ++++++++++++++++++ 15 files changed, 76 insertions(+), 51 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index e7725b53ba7..eb4c6a8817e 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -537,8 +537,10 @@ subscription with a warning when detection is inconclusive. See ## Compaction routing In **Dashboard → Overview → Compaction routing**, choose a model, which triggers it applies to, -and an optional reasoning effort, then click **Save**. Select **Use conversation model** and save -to remove the override. Changes apply to the next compaction request without restarting the proxy. +an optional reasoning effort, and whether the override covers all source models or only selected +sources. For selected sources, check individual models or provider-wide `provider/*` entries, +then click **Save**. Select **Use conversation model** and save to remove the override. Changes +apply to the next compaction request without restarting the proxy. Set `compactionRouting` in OpenCodex `config.json` to override the model Codex's compaction requests use. The setting is disabled when omitted. @@ -556,8 +558,9 @@ 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`. +existing all-model behavior; empty, duplicate, or malformed lists are invalid. The Dashboard +source-scope picker can edit the allowlist and preserves saved selectors absent from the current +catalog. You can also edit 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 diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 0515d077d57..d46fb6c8ad0 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -376,8 +376,8 @@ export const de: Record = { "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.sourcesHint": "Wendet die Überschreibung nur an, wenn das Quellmodell einer Komprimierungsanfrage einem ausgewählten Modell oder provider/* entspricht. Andere Anfragen verwenden weiterhin ihr eigenes Modell.", + "compactionRouting.sourcesGridTitle": "Komprimierungsanfragen umleiten, deren Quellmodell hiermit übereinstimmt:", "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.", @@ -386,8 +386,8 @@ export const de: Record = { "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.providerWarningScoped": "Mit dieser Einstellung senden nur Komprimierungsanfragen, deren Quellmodell {sources} entspricht, den vollständigen Gesprächsinhalt zur Zusammenfassung an {provider}; andere Anfragen verwenden weiterhin ihr eigenes Modell.", + "compactionRouting.comboWarningScoped": "Mit dieser Einstellung senden nur Komprimierungsanfragen, deren Quellmodell {sources} entspricht, den vollständigen Gesprächsinhalt zur Zusammenfassung an die Combo {combo}; andere Anfragen verwenden weiterhin ihr eigenes Modell. Die Combo versucht ihre Ziele ({providers}) der Reihe nach und verwendet das erste, das antwortet, sodass jedes davon passende Anfragen 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.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 69ff6f68ed9..3dce947a023 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -394,8 +394,8 @@ export const en = { "compactionRouting.sourcesModels": "Individual models", "compactionRouting.sourcesSaved": "Saved selectors not in the current catalog", "compactionRouting.sourcesNone": "Select at least one source, or switch back to all conversation models.", - "compactionRouting.sourcesHint": "Scope the override to compaction requests from chosen conversations. provider/* covers every model of that provider; unlisted conversations keep using their own model.", - "compactionRouting.sourcesGridTitle": "Reroute compaction requests from these sources:", + "compactionRouting.sourcesHint": "Apply the override only when a compaction request's source model matches a selected model or provider/*. Other requests keep using their own model.", + "compactionRouting.sourcesGridTitle": "Reroute compaction requests whose source model matches:", "compactionRouting.currentModel": "Use conversation model", "compactionRouting.currentEffort": "Keep request effort", "compactionRouting.effortHint": "Reasoning applies where supported by the compaction endpoint. The model must accept the full conversation.", @@ -404,8 +404,8 @@ export const en = { "compactionRouting.providerWarning": "With this setting, every covered compaction request sends the full conversation contents to {provider} for summarization.", "compactionRouting.comboWarning": "With this setting, every covered compaction request sends the full conversation contents to combo {combo} for summarization. The combo attempts its targets ({providers}) in order and uses the first that answers, so any one of them can receive the conversation.", "compactionRouting.comboProvidersUnknown": "its configured target providers", - "compactionRouting.providerWarningScoped": "With this setting, only compaction requests from {sources} send the full conversation contents to {provider} for summarization; other conversations keep their own model.", - "compactionRouting.comboWarningScoped": "With this setting, only compaction requests from {sources} send the full conversation contents to combo {combo} for summarization; other conversations keep their own model. The combo attempts its targets ({providers}) in order and uses the first that answers, so any one of them can receive those conversations.", + "compactionRouting.providerWarningScoped": "With this setting, only compaction requests whose source model matches {sources} send the full conversation contents to {provider} for summarization; other requests keep using their own model.", + "compactionRouting.comboWarningScoped": "With this setting, only compaction requests whose source model matches {sources} send the full conversation contents to combo {combo} for summarization; other requests keep using their own model. The combo attempts its targets ({providers}) in order and uses the first that answers, so any one of them can receive the matching requests.", "compactionRouting.loadFailed": "Could not load compaction settings.", "compactionRouting.saved": "Compaction settings saved.", "compactionRouting.saveFailed": "Could not save. Your changes are still here; try again.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 3707b0e6a17..4d168285a21 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -384,8 +384,8 @@ export const fr: Record = { "compactionRouting.sourcesModels": "Modèles individuels", "compactionRouting.sourcesSaved": "Sélecteurs enregistrés absents du catalogue actuel", "compactionRouting.sourcesNone": "Sélectionnez au moins une source, ou revenez à tous les modèles de conversation.", - "compactionRouting.sourcesHint": "Limite le remplacement aux requêtes de compaction des conversations choisies. provider/* couvre tous les modèles de ce fournisseur ; les conversations non listées gardent leur propre modèle.", - "compactionRouting.sourcesGridTitle": "Rerouter les requêtes de compaction issues de ces sources :", + "compactionRouting.sourcesHint": "Applique le remplacement uniquement si le modèle source de la requête de compaction correspond à un modèle choisi ou à provider/*. Les autres requêtes gardent leur propre modèle.", + "compactionRouting.sourcesGridTitle": "Rerouter les requêtes de compaction dont le modèle source correspond à :", "compactionRouting.currentModel": "Utiliser le modèle de la conversation", "compactionRouting.currentEffort": "Conserver l’effort de la requête", "compactionRouting.effortHint": "Le raisonnement s’applique si le point de terminaison de compaction le prend en charge. Le modèle doit accepter toute la conversation.", @@ -394,8 +394,8 @@ export const fr: Record = { "compactionRouting.providerWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation à {provider} pour la résumer.", "compactionRouting.comboWarning": "Avec ce réglage, chaque requête de compaction couverte envoie l’intégralité du contenu de la conversation au combo {combo} pour le résumer. Le combo essaie ses cibles ({providers}) dans l’ordre et retient la première qui répond, donc n’importe laquelle peut recevoir la conversation.", "compactionRouting.comboProvidersUnknown": "ses fournisseurs cibles configurés", - "compactionRouting.providerWarningScoped": "Avec ce réglage, seules les requêtes de compaction issues de {sources} envoient l’intégralité de la conversation à {provider} pour le résumer ; les autres conversations gardent leur propre modèle.", - "compactionRouting.comboWarningScoped": "Avec ce réglage, seules les requêtes de compaction issues de {sources} envoient l’intégralité de la conversation au combo {combo} pour le résumer ; les autres conversations gardent leur propre modèle. Le combo essaie ses cibles ({providers}) dans l’ordre et retient la première qui répond, donc n’importe laquelle peut recevoir ces conversations.", + "compactionRouting.providerWarningScoped": "Avec ce réglage, seules les requêtes de compaction dont le modèle source correspond à {sources} envoient l’intégralité de la conversation à {provider} pour la résumer ; les autres requêtes gardent leur propre modèle.", + "compactionRouting.comboWarningScoped": "Avec ce réglage, seules les requêtes de compaction dont le modèle source correspond à {sources} envoient l’intégralité de la conversation au combo {combo} pour la résumer ; les autres requêtes gardent leur propre modèle. Le combo essaie ses cibles ({providers}) dans l’ordre et retient la première qui répond, donc chacune peut recevoir les requêtes correspondantes.", "compactionRouting.loadFailed": "Impossible de charger les paramètres de compaction.", "compactionRouting.saved": "Paramètres de compaction enregistrés.", "compactionRouting.saveFailed": "Échec de l’enregistrement. Vos modifications sont conservées ; réessayez.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index ef97b608716..573b5a06a4a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -385,8 +385,8 @@ export const ja: Record = { "compactionRouting.sourcesModels": "個別モデル", "compactionRouting.sourcesSaved": "現在のカタログにない保存済みセレクター", "compactionRouting.sourcesNone": "ソースを少なくとも 1 つ選択するか、すべての会話モデルに戻してください。", - "compactionRouting.sourcesHint": "上書きを選択した会話からの圧縮リクエストに限定します。provider/* はそのプロバイダーの全モデルを対象にし、リストにない会話は従来どおり自身のモデルで圧縮します。", - "compactionRouting.sourcesGridTitle": "以下のソースからの圧縮リクエストをリルートします:", + "compactionRouting.sourcesHint": "圧縮リクエストの元モデルが選択したモデルまたは provider/* に一致する場合だけ上書きします。一致しないリクエストは元のモデルを使います。", + "compactionRouting.sourcesGridTitle": "元モデルが以下に一致する圧縮リクエストをリルートします:", "compactionRouting.currentModel": "会話のモデルを使用", "compactionRouting.currentEffort": "リクエストの推論強度を維持", "compactionRouting.effortHint": "圧縮エンドポイントが対応している場合に推論設定が適用されます。モデルは会話全体を受け入れられる必要があります。", @@ -395,8 +395,8 @@ export const ja: Record = { "compactionRouting.providerWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のために {provider} へ送信されます。", "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもが会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", - "compactionRouting.providerWarningScoped": "この設定では、{sources} からの圧縮リクエストのみが会話の全内容を要約のため {provider} へ送信し、それ以外の会話は従来どおり自身のモデルで圧縮されます。", - "compactionRouting.comboWarningScoped": "この設定では、{sources} からの圧縮リクエストのみが会話の全内容を要約のためコンボ {combo} へ送信し、それ以外の会話は従来どおり自身のモデルで圧縮されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもがこれらの会話を受け取る可能性があります。", + "compactionRouting.providerWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のため {provider} へ送信します。一致しないリクエストは元のモデルを使います。", + "compactionRouting.comboWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のためコンボ {combo} へ送信します。一致しないリクエストは元のモデルを使います。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、いずれも一致したリクエストを受け取る可能性があります。", "compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。", "compactionRouting.saved": "圧縮設定を保存しました。", "compactionRouting.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 64ab7fdc48d..9b5cfcd3634 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -380,8 +380,8 @@ export const ko: Record = { "compactionRouting.sourcesModels": "개별 모델", "compactionRouting.sourcesSaved": "현재 카탈로그에 없는 저장된 선택자", "compactionRouting.sourcesNone": "소스를 하나 이상 선택하거나 모든 대화 모델로 되돌리세요.", - "compactionRouting.sourcesHint": "재정의를 선택한 대화의 압축 요청으로 제한합니다. provider/*는 해당 제공자의 모든 모델을 포함하며, 목록에 없는 대화는 계속 자체 모델로 압축합니다.", - "compactionRouting.sourcesGridTitle": "다음 소스에서 온 압축 요청을 리라우팅합니다:", + "compactionRouting.sourcesHint": "압축 요청의 원본 모델이 선택한 모델 또는 provider/*와 일치할 때만 재정의를 적용합니다. 일치하지 않는 요청은 원래 모델을 사용합니다.", + "compactionRouting.sourcesGridTitle": "원본 모델이 다음 선택 항목과 일치하는 압축 요청을 다시 라우팅합니다:", "compactionRouting.currentModel": "대화 모델 사용", "compactionRouting.currentEffort": "요청의 추론 수준 유지", "compactionRouting.effortHint": "압축 엔드포인트가 지원하는 경우 추론 설정이 적용됩니다. 모델은 전체 대화를 수용할 수 있어야 합니다.", @@ -390,8 +390,8 @@ export const ko: Record = { "compactionRouting.providerWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 대화를 받을 수 있습니다.", "compactionRouting.comboProvidersUnknown": "구성된 대상 프로바이더", - "compactionRouting.providerWarningScoped": "이 설정을 사용하면 {sources}의 압축 요청만 전체 대화 내용을 요약을 위해 {provider}로 전송하며, 다른 대화는 계속 자체 모델로 압축합니다.", - "compactionRouting.comboWarningScoped": "이 설정을 사용하면 {sources}의 압축 요청만 전체 대화 내용을 요약을 위해 콤보 {combo}로 전송하며, 다른 대화는 계속 자체 모델로 압축합니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 이 대화를 받을 수 있습니다.", + "compactionRouting.providerWarningScoped": "이 설정을 사용하면 원본 모델이 {sources}와 일치하는 압축 요청만 전체 대화 내용을 요약을 위해 {provider}로 전송합니다. 일치하지 않는 요청은 원래 모델을 사용합니다.", + "compactionRouting.comboWarningScoped": "이 설정을 사용하면 원본 모델이 {sources}와 일치하는 압축 요청만 전체 대화 내용을 요약을 위해 콤보 {combo}로 전송합니다. 일치하지 않는 요청은 원래 모델을 사용합니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 어느 대상이든 일치한 요청을 받을 수 있습니다.", "compactionRouting.loadFailed": "압축 설정을 불러올 수 없습니다.", "compactionRouting.saved": "압축 설정을 저장했습니다.", "compactionRouting.saveFailed": "저장하지 못했습니다. 변경 사항은 유지됩니다. 다시 시도하세요.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c41040dd653..138c28f7e7d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -385,8 +385,8 @@ export const ru: Record = { "compactionRouting.sourcesModels": "Отдельные модели", "compactionRouting.sourcesSaved": "Сохранённые селекторы вне текущего каталога", "compactionRouting.sourcesNone": "Выберите хотя бы один источник или вернитесь ко всем моделям диалога.", - "compactionRouting.sourcesHint": "Ограничивает переопределение запросами сжатия из выбранных диалогов. provider/* охватывает все модели этого провайдера; остальные диалоги по-прежнему используют собственную модель.", - "compactionRouting.sourcesGridTitle": "Перенаправлять запросы сжатия из этих источников:", + "compactionRouting.sourcesHint": "Применяет переопределение, только если исходная модель запроса сжатия совпадает с выбранной моделью или provider/*. Остальные запросы используют свою модель.", + "compactionRouting.sourcesGridTitle": "Перенаправлять запросы сжатия, исходная модель которых совпадает с:", "compactionRouting.currentModel": "Использовать модель разговора", "compactionRouting.currentEffort": "Сохранить уровень из запроса", "compactionRouting.effortHint": "Уровень рассуждений применяется, если его поддерживает конечная точка сжатия. Модель должна вмещать весь разговор.", @@ -395,8 +395,8 @@ export const ru: Record = { "compactionRouting.providerWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо пробует свои цели ({providers}) по порядку и использует первую ответившую, поэтому разговор может получить любая из них.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", - "compactionRouting.providerWarningScoped": "При этой настройке только запросы сжатия из {sources} отправляют полное содержимое диалога в {provider} для составления сводки; остальные диалоги по-прежнему используют собственную модель.", - "compactionRouting.comboWarningScoped": "При этой настройке только запросы сжатия из {sources} отправляют полное содержимое диалога комбо {combo} для составления сводки; остальные диалоги по-прежнему используют собственную модель. Комбо перебирает цели ({providers}) по порядку и использует первую ответившую, поэтому любая из них может получить эти диалоги.", + "compactionRouting.providerWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога в {provider} для составления сводки; остальные запросы используют свою модель.", + "compactionRouting.comboWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога комбо {combo} для составления сводки; остальные запросы используют свою модель. Комбо перебирает цели ({providers}) по порядку и использует первую ответившую, поэтому любая из них может получить подходящие запросы.", "compactionRouting.loadFailed": "Не удалось загрузить настройки сжатия.", "compactionRouting.saved": "Настройки сжатия сохранены.", "compactionRouting.saveFailed": "Не удалось сохранить. Изменения остались; попробуйте снова.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index b1b4319729a..15d8a81e6a5 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -386,8 +386,8 @@ export const tr: Record = { "compactionRouting.sourcesModels": "Tekil modeller", "compactionRouting.sourcesSaved": "Geçerli katalogda olmayan kayıtlı seçiciler", "compactionRouting.sourcesNone": "En az bir kaynak seçin veya tüm konuşma modellerine dönün.", - "compactionRouting.sourcesHint": "Geçersiz kılmayı seçilen konuşmaların sıkıştırma istekleriyle sınırlar. provider/* o sağlayıcının tüm modellerini kapsar; listelenmeyen konuşmalar kendi modelini kullanmaya devam eder.", - "compactionRouting.sourcesGridTitle": "Bu kaynaklardan gelen sıkıştırma isteklerini yeniden yönlendir:", + "compactionRouting.sourcesHint": "Geçersiz kılmayı yalnızca özetleme isteğinin kaynak modeli seçilen modelle veya provider/* ile eşleştiğinde uygular. Eşleşmeyen istekler kendi modelini kullanır.", + "compactionRouting.sourcesGridTitle": "Kaynak modeli aşağıdakilerle eşleşen özetleme isteklerini yeniden yönlendir:", "compactionRouting.currentModel": "Konuşma modelini kullan", "compactionRouting.currentEffort": "İsteğin düzeyini koru", "compactionRouting.effortHint": "Akıl yürütme, özetleme uç noktası destekliyorsa uygulanır. Model tüm konuşmayı kabul edebilmelidir.", @@ -396,8 +396,8 @@ export const tr: Record = { "compactionRouting.providerWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {provider} sağlayıcısına gönderir.", "compactionRouting.comboWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {combo} kombosuna gönderir. Kombo hedeflerini ({providers}) sırayla dener ve yanıt veren ilkini kullanır; dolayısıyla bunlardan herhangi biri konuşmayı alabilir.", "compactionRouting.comboProvidersUnknown": "yapılandırılmış hedef sağlayıcıları", - "compactionRouting.providerWarningScoped": "Bu ayarla yalnızca {sources} kaynaklı sıkıştırma istekleri konuşmanın tüm içeriğini özet için {provider} hedefine gönderir; diğer konuşmalar kendi modelini kullanmaya devam eder.", - "compactionRouting.comboWarningScoped": "Bu ayarla yalnızca {sources} kaynaklı sıkıştırma istekleri konuşmanın tüm içeriğini özet için {combo} kombosuna gönderir; diğer konuşmalar kendi modelini kullanmaya devam eder. Kombo hedeflerini ({providers}) sırayla dener ve yanıt veren ilkini kullanır; dolayısıyla bunlardan herhangi biri bu konuşmaları alabilir.", + "compactionRouting.providerWarningScoped": "Bu ayarla yalnızca kaynak modeli {sources} ile eşleşen özetleme istekleri konuşmanın tüm içeriğini {provider} hedefine gönderir; eşleşmeyen istekler kendi modelini kullanır.", + "compactionRouting.comboWarningScoped": "Bu ayarla yalnızca kaynak modeli {sources} ile eşleşen özetleme istekleri konuşmanın tüm içeriğini {combo} kombosuna gönderir; eşleşmeyen istekler kendi modelini kullanır. Kombo hedeflerini ({providers}) sırayla dener ve yanıt veren ilkini kullanır; dolayısıyla bunlardan herhangi biri eşleşen istekleri alabilir.", "compactionRouting.loadFailed": "Özetleme ayarları yüklenemedi.", "compactionRouting.saved": "Özetleme ayarları kaydedildi.", "compactionRouting.saveFailed": "Kaydedilemedi. Değişiklikleriniz korunuyor; tekrar deneyin.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index c8bf4ba5b1c..4ff98c6b6ee 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -384,8 +384,8 @@ export const vi: Record = { "compactionRouting.sourcesModels": "Từng model", "compactionRouting.sourcesSaved": "Bộ chọn đã lưu không có trong danh mục hiện tại", "compactionRouting.sourcesNone": "Chọn ít nhất một nguồn, hoặc quay lại tất cả model hội thoại.", - "compactionRouting.sourcesHint": "Giới hạn ghi đè chỉ cho các yêu cầu nén từ những hội thoại đã chọn. provider/* bao gồm mọi model của nhà cung cấp đó; hội thoại không được liệt kê vẫn nén bằng model của chính nó.", - "compactionRouting.sourcesGridTitle": "Định tuyến lại các yêu cầu nén từ các nguồn sau:", + "compactionRouting.sourcesHint": "Chỉ áp dụng ghi đè khi model nguồn của yêu cầu nén khớp với model đã chọn hoặc provider/*. Các yêu cầu không khớp vẫn dùng model của chính mình.", + "compactionRouting.sourcesGridTitle": "Định tuyến lại yêu cầu nén có model nguồn khớp với:", "compactionRouting.currentModel": "Dùng model của cuộc trò chuyện", "compactionRouting.currentEffort": "Giữ mức suy luận của yêu cầu", "compactionRouting.effortHint": "Mức suy luận được áp dụng nếu điểm cuối nén hỗ trợ. Model phải tiếp nhận được toàn bộ cuộc trò chuyện.", @@ -394,8 +394,8 @@ export const vi: Record = { "compactionRouting.providerWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến {provider} để tóm tắt.", "compactionRouting.comboWarning": "Với thiết lập này, mỗi yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ nội dung cuộc trò chuyện đến combo {combo} để tóm tắt. Combo thử lần lượt các đích ({providers}) và dùng đích trả lời đầu tiên, nên bất kỳ đích nào cũng có thể nhận cuộc trò chuyện.", "compactionRouting.comboProvidersUnknown": "các nhà cung cấp đích đã cấu hình của nó", - "compactionRouting.providerWarningScoped": "Với thiết lập này, chỉ các yêu cầu nén từ {sources} mới gửi toàn bộ nội dung cuộc trò chuyện đến {provider} để tóm tắt; các cuộc trò chuyện khác vẫn nén bằng model của chính nó.", - "compactionRouting.comboWarningScoped": "Với thiết lập này, chỉ các yêu cầu nén từ {sources} mới gửi toàn bộ nội dung cuộc trò chuyện đến combo {combo} để tóm tắt; các cuộc trò chuyện khác vẫn nén bằng model của chính nó. Combo thử lần lượt các đích ({providers}) và dùng đích trả lời đầu tiên, nên bất kỳ đích nào cũng có thể nhận những cuộc trò chuyện đó.", + "compactionRouting.providerWarningScoped": "Với thiết lập này, chỉ các yêu cầu nén có model nguồn khớp với {sources} mới gửi toàn bộ nội dung cuộc trò chuyện đến {provider} để tóm tắt; yêu cầu không khớp vẫn dùng model nguồn.", + "compactionRouting.comboWarningScoped": "Với thiết lập này, chỉ các yêu cầu nén có model nguồn khớp với {sources} mới gửi toàn bộ nội dung cuộc trò chuyện đến combo {combo} để tóm tắt; yêu cầu không khớp vẫn dùng model nguồn. Combo thử lần lượt các đích ({providers}) và dùng đích trả lời đầu tiên, nên bất kỳ đích nào cũng có thể nhận yêu cầu khớp.", "compactionRouting.loadFailed": "Không thể tải cài đặt nén.", "compactionRouting.saved": "Đã lưu cài đặt nén.", "compactionRouting.saveFailed": "Không thể lưu. Thay đổi của bạn vẫn còn; hãy thử lại.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index fe9db711438..e59bf934965 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -275,8 +275,8 @@ export const zhTW: Record = { "compactionRouting.sourcesModels": "單個模型", "compactionRouting.sourcesSaved": "已保存但不在目前目錄中的選取器", "compactionRouting.sourcesNone": "至少選擇一個來源,或改回所有對話模型。", - "compactionRouting.sourcesHint": "將覆寫限定為來自所選對話的壓縮請求。provider/* 覆蓋該提供商的全部模型;未列出的對話仍使用自身模型壓縮。", - "compactionRouting.sourcesGridTitle": "改路由以下來源對話的壓縮請求:", + "compactionRouting.sourcesHint": "僅當壓縮請求的來源模型符合所選模型或 provider/* 時才使用覆寫;其他請求仍使用自身模型壓縮。", + "compactionRouting.sourcesGridTitle": "改路由來源模型符合以下選擇器的壓縮請求:", "compactionRouting.currentModel": "使用對話模型", "compactionRouting.currentEffort": "保留請求的推理強度", "compactionRouting.effortHint": "推理設定僅在壓縮端點支援時生效。模型必須能容納完整對話。", @@ -285,8 +285,8 @@ export const zhTW: Record = { "compactionRouting.providerWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給 {provider} 進行摘要。", "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到該對話。", "compactionRouting.comboProvidersUnknown": "其已設定的目標供應商", - "compactionRouting.providerWarningScoped": "啟用此設定後,僅來自 {sources} 的壓縮請求會將完整對話內容傳送給 {provider} 進行摘要;其他對話仍使用自身模型壓縮。", - "compactionRouting.comboWarningScoped": "啟用此設定後,僅來自 {sources} 的壓縮請求會將完整對話內容傳送給組合 {combo} 進行摘要;其他對話仍使用自身模型壓縮。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到這些對話。", + "compactionRouting.providerWarningScoped": "啟用此設定後,僅當壓縮請求的來源模型符合 {sources} 時,完整對話內容才會傳送給 {provider} 進行摘要;其他請求仍使用自身模型壓縮。", + "compactionRouting.comboWarningScoped": "啟用此設定後,僅當壓縮請求的來源模型符合 {sources} 時,完整對話內容才會傳送給組合 {combo} 進行摘要;其他請求仍使用自身模型壓縮。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到符合條件的請求。", "compactionRouting.loadFailed": "無法載入壓縮設定。", "compactionRouting.saved": "壓縮設定已儲存。", "compactionRouting.saveFailed": "儲存失敗。變更仍然保留,請重試。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 9562e186175..d878dcbf8d6 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -380,8 +380,8 @@ export const zh: Record = { "compactionRouting.sourcesModels": "单个模型", "compactionRouting.sourcesSaved": "已保存但不在当前目录中的选择器", "compactionRouting.sourcesNone": "至少选择一个来源,或改回所有对话模型。", - "compactionRouting.sourcesHint": "将覆盖限定为来自所选对话的压缩请求。provider/* 覆盖该提供商的全部模型;未列出的对话仍使用自身模型压缩。", - "compactionRouting.sourcesGridTitle": "改路由以下来源对话的压缩请求:", + "compactionRouting.sourcesHint": "仅当压缩请求的来源模型匹配所选模型或 provider/* 时才使用覆盖;其他请求仍使用自身模型压缩。", + "compactionRouting.sourcesGridTitle": "改路由来源模型匹配以下选择器的压缩请求:", "compactionRouting.currentModel": "使用对话模型", "compactionRouting.currentEffort": "保留请求的推理强度", "compactionRouting.effortHint": "推理设置仅在压缩端点支持时生效。模型必须能容纳完整对话。", @@ -390,8 +390,8 @@ export const zh: Record = { "compactionRouting.providerWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给 {provider} 进行摘要。", "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到该对话。", "compactionRouting.comboProvidersUnknown": "其已配置的目标提供商", - "compactionRouting.providerWarningScoped": "启用此设置后,仅来自 {sources} 的压缩请求会将完整对话内容发送给 {provider} 进行摘要;其他对话仍使用自身模型压缩。", - "compactionRouting.comboWarningScoped": "启用此设置后,仅来自 {sources} 的压缩请求会将完整对话内容发送给组合 {combo} 进行摘要;其他对话仍使用自身模型压缩。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到这些对话。", + "compactionRouting.providerWarningScoped": "启用此设置后,仅当压缩请求的来源模型匹配 {sources} 时,完整对话内容才会发送给 {provider} 进行摘要;其他请求仍使用自身模型压缩。", + "compactionRouting.comboWarningScoped": "启用此设置后,仅当压缩请求的来源模型匹配 {sources} 时,完整对话内容才会发送给组合 {combo} 进行摘要;其他请求仍使用自身模型压缩。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到匹配的请求。", "compactionRouting.loadFailed": "无法加载压缩设置。", "compactionRouting.saved": "压缩设置已保存。", "compactionRouting.saveFailed": "保存失败。更改仍然保留,请重试。", diff --git a/gui/tests/compaction-routing-panel.test.tsx b/gui/tests/compaction-routing-panel.test.tsx index 4db731caf11..dd755dda02c 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -112,7 +112,7 @@ test("names each control and the source grid so the summarizer and the covered s } await choose("model", "gateway/cheap"); await choose("sources", "Selected sources only"); - expect(container.textContent).toContain("Reroute compaction requests from these sources:"); + expect(container.textContent).toContain("Reroute compaction requests whose source model matches:"); }); test("the trigger selection round-trips and discloses automatic compaction", async () => { @@ -204,12 +204,12 @@ test("the disclosure names the selected sources instead of claiming every reques expect(container.querySelector('[role="note"]')).toBeNull(); await act(async () => { sourceCheckbox("gateway/*")!.click(); }); const note = container.querySelector('[role="note"]')?.textContent ?? ""; - expect(note).toContain("compaction requests from gateway/*"); + expect(note).toContain("compaction requests whose source model matches gateway/*"); expect(note).toContain("to gateway for summarization"); expect(note).not.toContain("every covered compaction request"); await choose("model", "combo/compact"); const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; - expect(comboNote).toContain("compaction requests from gateway/*"); + expect(comboNote).toContain("compaction requests whose source model matches gateway/*"); expect(comboNote).toContain("combo combo/compact"); }); @@ -220,7 +220,7 @@ test("the disclosure names the provider endpoint host when the provider is known // A scoped override keeps the endpoint next to the sources it covers. await choose("sources", "Selected sources only"); await act(async () => { sourceCheckbox("gateway/*")!.click(); }); - expect(container.querySelector('[role="note"]')?.textContent).toContain("from gateway/* send the full conversation contents to gateway (gw.example.com)"); + expect(container.querySelector('[role="note"]')?.textContent).toContain("source model matches gateway/* send the full conversation contents to gateway (gw.example.com)"); // Combo targets carry their own endpoints, so "any of them" is checkable against the list. await choose("model", "combo/compact"); const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 2fd879b148c..71d1b41df02 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -154,7 +154,9 @@ export function parseRequest( }; const preservePendingReplay = () => { const replay = pendingReasoning.filter( - entry => entry.envelopeSigned || (entry.part.type === "thinking" && entry.part.redacted?.length), + entry => (compactionRequest && entry.part.type === "text") + || entry.envelopeSigned + || (entry.part.type === "thinking" && entry.part.redacted?.length), ); if (replay.length > 0) { ensureAssistantPlaceholder(messages, data.model, now).content.push(...replay.map(entry => entry.part)); diff --git a/structure/dashboard-and-usage.md b/structure/dashboard-and-usage.md index 55a617fe567..cc0dc17cd84 100644 --- a/structure/dashboard-and-usage.md +++ b/structure/dashboard-and-usage.md @@ -462,8 +462,10 @@ provides labeled model, trigger, source-scope and effort selectors with an expli standing note that the selected model's provider receives the entire conversation, and a warning naming that provider once a model is chosen; for a combo selector the warning lists the combo's target providers from `GET /api/combos` and states that failover targets receive the conversation -too. The source scope renders as provider wildcards and individual models under a title naming -them as the rerouted sources, and saving with an empty selection is refused client-side because +too. The source-scope picker edits `sourceModels` as individual model selectors and provider-wide +`provider/*` selectors. It keeps saved selectors missing from the current catalog visible, +and explains that matching uses each incoming compaction request's source model, not a +conversation id. Saving a selected scope with no selectors is refused client-side because the schema would drop the override. `GET /api/settings` returns the override or null; `PUT /api/settings` accepts a complete validated object or null to clear it. Save failure restores live settings and deletion provenance; the dashboard retains the draft for retry. diff --git a/tests/responses/responses-parser.test.ts b/tests/responses/responses-parser.test.ts index 77c1a304094..f6085c13b91 100644 --- a/tests/responses/responses-parser.test.ts +++ b/tests/responses/responses-parser.test.ts @@ -1305,4 +1305,22 @@ describe("compaction turns expose reasoning to the summarizer", () => { expect.arrayContaining([expect.objectContaining({ type: "thinking", thinking: "应该保留推理" })]), ); }); + + test("keeps trailing reasoning when compaction follows without an assistant message", () => { + const parsed = parse([userMsg, reasoningItem("最终决策理由"), { type: "compaction_trigger" }]); + expect(parsed.context.messages).toMatchObject([ + { role: "user" }, + { role: "assistant", content: [{ type: "text", text: expect.stringContaining("最终决策理由") }] }, + ]); + }); + + test("keeps reasoning on its assistant turn before the next user message", () => { + const followUp = { type: "message", role: "user", content: [{ type: "input_text", text: "继续" }] }; + const parsed = parse([userMsg, reasoningItem("上一轮的决策理由"), followUp, { type: "compaction_trigger" }]); + expect(parsed.context.messages).toMatchObject([ + { role: "user", content: "调整压缩方案" }, + { role: "assistant", content: [{ type: "text", text: expect.stringContaining("上一轮的决策理由") }] }, + { role: "user", content: "继续" }, + ]); + }); });