From 5f103f4bae1ed7c9ded981b7aecbd62c0ea12028 Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Mon, 28 Sep 2026 09:24:54 +0800 Subject: [PATCH 1/5] feat(compaction): scope routing overrides by source model Split out from #5631 per maintainer triage: this PR contains only the sourceModels scoping (config schema, routing match, dashboard editor, docs and tests). The reasoning-to-summarizer disclosure is intentionally excluded and will be proposed separately. --- .../docs/reference/configuration/server.md | 16 +- gui/src/components/CompactionRoutingPanel.tsx | 148 +++++++++++++++--- gui/src/i18n/de.ts | 11 ++ gui/src/i18n/en.ts | 11 ++ gui/src/i18n/fr.ts | 11 ++ gui/src/i18n/ja.ts | 11 ++ gui/src/i18n/ko.ts | 11 ++ gui/src/i18n/ru.ts | 11 ++ gui/src/i18n/tr.ts | 11 ++ gui/src/i18n/vi.ts | 11 ++ gui/src/i18n/zh-TW.ts | 11 ++ gui/src/i18n/zh.ts | 11 ++ gui/src/pages/dashboard-overview-panels.tsx | 2 +- gui/src/styles.css | 11 ++ gui/tests/compaction-routing-panel.test.tsx | 120 +++++++++++++- src/config/schema/compaction-triggers.ts | 8 + src/config/schema/leaf-validators.ts | 4 +- src/server/responses/compaction-routing.ts | 9 +- src/types/config.ts | 2 + structure/config.md | 4 +- structure/dashboard-and-usage.md | 11 +- structure/transports/responses-failover.md | 20 +-- tests/config/settings-stream-mode.test.ts | 2 +- tests/fixtures/file-size-baseline.json | 2 +- .../responses-compaction-override.test.ts | 73 +++++++++ 25 files changed, 491 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 f0d92698dbf..071b64d03cb 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -606,8 +606,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. @@ -623,6 +625,16 @@ 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. 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 +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..f1d1f235779 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,60 @@ 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); }} /> - { setModel(value); if (!value) { setEffort(""); setTriggers("manual"); setScope("all"); setSources([]); } setFeedback(null); }} /> +
+
+ + ({ value, label: t(SCOPE_LABELS[value]!) }))} + onChange={value => { setScope(value); setFeedback(null); }} /> +
+
+ + { toggleSource(selector); }} /> {selector} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index c6f65c375e0..99a17f45d25 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -429,7 +429,7 @@ export const de: Record = { "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, 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.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 wählt nach ihrer Routing-Strategie eines ihrer Ziele ({providers}) aus, 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 61a718c974c..2baed52a84b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -447,7 +447,7 @@ export const en = { "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 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.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 picks one of its targets ({providers}) by its routing strategy, so any of them can receive a matching request.", "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 82eb2ca2dc9..e0084ec19ca 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -437,7 +437,7 @@ export const fr: Record = { "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 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.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 choisit l’une de ses cibles ({providers}) selon sa stratégie de routage, 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 3c389002b59..8fd82de72ac 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -438,7 +438,7 @@ export const ja: Record = { "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもが会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", "compactionRouting.providerWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のため {provider} へ送信します。一致しないリクエストは元のモデルを使います。", - "compactionRouting.comboWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のためコンボ {combo} へ送信します。一致しないリクエストは元のモデルを使います。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、いずれも一致したリクエストを受け取る可能性があります。", + "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 722f8c19133..5bb8e0e6954 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -433,7 +433,7 @@ export const ko: Record = { "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 대화를 받을 수 있습니다.", "compactionRouting.comboProvidersUnknown": "구성된 대상 프로바이더", "compactionRouting.providerWarningScoped": "이 설정을 사용하면 원본 모델이 {sources}와 일치하는 압축 요청만 전체 대화 내용을 요약을 위해 {provider}로 전송합니다. 일치하지 않는 요청은 원래 모델을 사용합니다.", - "compactionRouting.comboWarningScoped": "이 설정을 사용하면 원본 모델이 {sources}와 일치하는 압축 요청만 전체 대화 내용을 요약을 위해 콤보 {combo}로 전송합니다. 일치하지 않는 요청은 원래 모델을 사용합니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 어느 대상이든 일치한 요청을 받을 수 있습니다.", + "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 158f78310aa..4a8f9f915aa 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -438,7 +438,7 @@ export const ru: Record = { "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо пробует свои цели ({providers}) по порядку и использует первую ответившую, поэтому разговор может получить любая из них.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", "compactionRouting.providerWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога в {provider} для составления сводки; остальные запросы используют свою модель.", - "compactionRouting.comboWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога комбо {combo} для составления сводки; остальные запросы используют свою модель. Комбо перебирает цели ({providers}) по порядку и использует первую ответившую, поэтому любая из них может получить подходящие запросы.", + "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 ff3bbfc4030..e575563cb1e 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -439,7 +439,7 @@ export const tr: Record = { "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 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.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, yönlendirme stratejisine göre hedeflerinden ({providers}) birini seçer; 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 4d20a36a988..3d8135e6198 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -437,7 +437,7 @@ export const vi: Record = { "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 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.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 chọn một trong các đích ({providers}) theo chiến lược định tuyến của 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 62feb83615d..97cde3e3f25 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -315,7 +315,7 @@ export const zhTW: Record = { "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到該對話。", "compactionRouting.comboProvidersUnknown": "其已設定的目標供應商", "compactionRouting.providerWarningScoped": "啟用此設定後,僅當壓縮請求的來源模型符合 {sources} 時,完整對話內容才會傳送給 {provider} 進行摘要;其他請求仍使用自身模型壓縮。", - "compactionRouting.comboWarningScoped": "啟用此設定後,僅當壓縮請求的來源模型符合 {sources} 時,完整對話內容才會傳送給組合 {combo} 進行摘要;其他請求仍使用自身模型壓縮。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到符合條件的請求。", + "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 f870f153007..36fb751ef3e 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -433,7 +433,7 @@ export const zh: Record = { "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到该对话。", "compactionRouting.comboProvidersUnknown": "其已配置的目标提供商", "compactionRouting.providerWarningScoped": "启用此设置后,仅当压缩请求的来源模型匹配 {sources} 时,完整对话内容才会发送给 {provider} 进行摘要;其他请求仍使用自身模型压缩。", - "compactionRouting.comboWarningScoped": "启用此设置后,仅当压缩请求的来源模型匹配 {sources} 时,完整对话内容才会发送给组合 {combo} 进行摘要;其他请求仍使用自身模型压缩。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到匹配的请求。", + "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 dd755dda02c..fd4998fe29c 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -232,6 +232,29 @@ test("the disclosure names the provider endpoint host when the provider is known expect(container.querySelector('[role="note"]')?.textContent).toContain("gateway (not a url)"); }); +test("offers a provider-wide selector for a configured provider with no catalog models", async () => { + // A provider whose catalog is empty or unlisted can still serve provider-qualified ids, so + // its wildcard must come from the configured provider list, not only from catalog rows. + await render("", [{ name: "lonely", baseUrl: "https://lonely.example/v1" }]); + await choose("model", "gateway/cheap"); + await choose("sources", "Selected sources only"); + expect(sourceCheckbox("lonely/*")).toBeDefined(); +}); + +test("the scoped combo disclosure stays strategy-neutral", async () => { + await render(); + await choose("model", "gateway/cheap"); + await choose("sources", "Selected sources only"); + await act(async () => { sourceCheckbox("gateway/*")!.click(); }); + await choose("model", "combo/compact"); + const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(comboNote).toContain("combo combo/compact"); + // Combos route by their own strategy (failover, round-robin, ...), so the warning must not + // promise configured order or first-answer selection. + expect(comboNote).not.toContain("in order"); + expect(comboNote).toContain("routing strategy"); +}); + test("failed save retains the draft and allows retry", async () => { await render(); await choose("model", "gateway/cheap"); From ff8e2c874781d01ba7b17beddd973381aee1bfad Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Mon, 28 Sep 2026 23:16:49 +0800 Subject: [PATCH 4/5] fix(gui): disclose combo failover may send the conversation to multiple targets --- gui/src/i18n/de.ts | 4 ++-- gui/src/i18n/en.ts | 4 ++-- gui/src/i18n/fr.ts | 4 ++-- gui/src/i18n/ja.ts | 4 ++-- gui/src/i18n/ko.ts | 4 ++-- gui/src/i18n/ru.ts | 4 ++-- gui/src/i18n/tr.ts | 4 ++-- gui/src/i18n/vi.ts | 4 ++-- gui/src/i18n/zh-TW.ts | 4 ++-- gui/src/i18n/zh.ts | 4 ++-- gui/tests/compaction-routing-panel.test.tsx | 17 ++++++++++++----- 11 files changed, 32 insertions(+), 25 deletions(-) diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 99a17f45d25..39dd6865ef9 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -426,10 +426,10 @@ export const de: Record = { "compactionRouting.dataNotice": "Eine abgedeckte Komprimierungsanfrage sendet das gesamte Gespräch zur Zusammenfassung an den Anbieter des gewählten Modells, auch wenn das Gespräch bei einem anderen Anbieter läuft.", "compactionRouting.autoNotice": "Automatische Komprimierung läuft von selbst, daher kann ein langes Gespräch an diesen Anbieter gehen, ohne dass du es angefordert hast.", "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.comboWarning": "Mit dieser Einstellung sendet jede abgedeckte Komprimierungsanfrage den vollständigen Gesprächsinhalt zur Zusammenfassung an die Combo {combo}. Die Combo wählt nach ihrer Routing-Strategie eines ihrer Ziele ({providers}) aus, kann dieselbe Anfrage mit dem vollständigen Gesprächsinhalt nach einem wiederholbaren Fehler aber bei einem anderen Ziel wiederholen, sodass eines oder mehrere Zielanbieter das Gespräch erhalten können.", "compactionRouting.comboProvidersUnknown": "ihre konfigurierten Zielanbieter", "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 wählt nach ihrer Routing-Strategie eines ihrer Ziele ({providers}) aus, sodass jedes davon passende Anfragen erhalten kann.", + "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 wählt nach ihrer Routing-Strategie eines ihrer Ziele ({providers}) aus, kann dieselbe Anfrage mit dem vollständigen Gesprächsinhalt nach einem wiederholbaren Fehler aber bei einem anderen Ziel wiederholen, sodass eines oder mehrere Zielanbieter das Gespräch erhalten können.", "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 2baed52a84b..55550f237b9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -444,10 +444,10 @@ export const en = { "compactionRouting.dataNotice": "A covered compaction request sends the entire conversation to the selected model's provider for summarization, even when the conversation runs on another provider.", "compactionRouting.autoNotice": "Automatic compaction runs on its own, so a long conversation can be sent to that provider without you asking for it.", "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.comboWarning": "With this setting, every covered compaction request sends the full conversation contents to combo {combo} for summarization. The combo picks one of its targets ({providers}) by its routing strategy, but after a retryable failure it may retry the same full-conversation request on another target, so one or more target providers can receive the conversation.", "compactionRouting.comboProvidersUnknown": "its configured target providers", "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 picks one of its targets ({providers}) by its routing strategy, so any of them can receive a matching request.", + "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 picks one of its targets ({providers}) by its routing strategy, but after a retryable failure it may retry the same full-conversation request on another target, so one or more target providers can receive the conversation.", "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 e0084ec19ca..e7bfe39e0f8 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -434,10 +434,10 @@ export const fr: Record = { "compactionRouting.dataNotice": "Une requête de compaction couverte envoie toute la conversation au fournisseur du modèle choisi pour la résumer, même si la conversation s’exécute chez un autre fournisseur.", "compactionRouting.autoNotice": "La compaction automatique se déclenche d’elle-même : une longue conversation peut donc partir chez ce fournisseur sans que vous l’ayez demandé.", "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.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 choisit l’une de ses cibles ({providers}) selon sa stratégie de routage, mais après un échec réessayable il peut réessayer la même requête avec l’intégralité de la conversation sur une autre cible, donc une ou plusieurs cibles peuvent recevoir la conversation.", "compactionRouting.comboProvidersUnknown": "ses fournisseurs cibles configurés", "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 choisit l’une de ses cibles ({providers}) selon sa stratégie de routage, donc chacune peut recevoir les requêtes correspondantes.", + "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 choisit l’une de ses cibles ({providers}) selon sa stratégie de routage, mais après un échec réessayable il peut réessayer la même requête avec l’intégralité de la conversation sur une autre cible, donc une ou plusieurs cibles peuvent recevoir la conversation.", "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 8fd82de72ac..490fe341b3b 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -435,10 +435,10 @@ export const ja: Record = { "compactionRouting.dataNotice": "適用対象の圧縮リクエストは、会話が別のプロバイダーで動いていても、会話全体を選択したモデルのプロバイダーへ送信して要約します。", "compactionRouting.autoNotice": "自動圧縮はユーザーが求めなくても実行されるため、長い会話が知らないうちにそのプロバイダーへ送信されることがあります。", "compactionRouting.providerWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のために {provider} へ送信されます。", - "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもが会話を受け取る可能性があります。", + "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはルーティング戦略に従ってターゲット({providers})のいずれかを選びますが、再試行可能な失敗の後には同じ会話全内容のリクエストを別のターゲットで再試行する可能性があるため、ターゲットのうち 1 つ以上が会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", "compactionRouting.providerWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のため {provider} へ送信します。一致しないリクエストは元のモデルを使います。", - "compactionRouting.comboWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のためコンボ {combo} へ送信します。一致しないリクエストは元のモデルを使います。コンボはルーティング戦略に従ってターゲット({providers})のいずれかを選ぶため、どれも一致したリクエストを受け取る可能性があります。", + "compactionRouting.comboWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のためコンボ {combo} へ送信します。一致しないリクエストは元のモデルを使います。コンボはルーティング戦略に従ってターゲット({providers})のいずれかを選びますが、再試行可能な失敗の後には同じ会話全内容のリクエストを別のターゲットで再試行するため、ターゲットのうち 1 つ以上が会話を受け取る可能性があります。", "compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。", "compactionRouting.saved": "圧縮設定を保存しました。", "compactionRouting.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 5bb8e0e6954..47251d12886 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -430,10 +430,10 @@ export const ko: Record = { "compactionRouting.dataNotice": "적용 대상 압축 요청은 대화가 다른 프로바이더에서 실행 중이더라도 전체 대화를 선택한 모델의 프로바이더로 보내 요약합니다.", "compactionRouting.autoNotice": "자동 압축은 사용자가 요청하지 않아도 실행되므로, 긴 대화가 예고 없이 해당 프로바이더로 전송될 수 있습니다.", "compactionRouting.providerWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", - "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 대화를 받을 수 있습니다.", + "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 라우팅 전략에 따라 대상({providers}) 중 하나를 선택하지만, 재시도 가능한 실패가 발생하면 동일한 전체 대화 요청을 다른 대상에서 재시도할 수 있으므로, 대상 중 하나 이상이 대화를 받을 수 있습니다.", "compactionRouting.comboProvidersUnknown": "구성된 대상 프로바이더", "compactionRouting.providerWarningScoped": "이 설정을 사용하면 원본 모델이 {sources}와 일치하는 압축 요청만 전체 대화 내용을 요약을 위해 {provider}로 전송합니다. 일치하지 않는 요청은 원래 모델을 사용합니다.", - "compactionRouting.comboWarningScoped": "이 설정을 사용하면 원본 모델이 {sources}와 일치하는 압축 요청만 전체 대화 내용을 요약을 위해 콤보 {combo}로 전송합니다. 일치하지 않는 요청은 원래 모델을 사용합니다. 콤보는 라우팅 전략에 따라 대상({providers}) 중 하나를 선택하므로, 어느 대상이든 일치한 요청을 받을 수 있습니다.", + "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 4a8f9f915aa..7d3fb3cc1ef 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -435,10 +435,10 @@ export const ru: Record = { "compactionRouting.dataNotice": "Охваченный запрос сжатия отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", "compactionRouting.autoNotice": "Автоматическое сжатие запускается само, поэтому длинный разговор может уйти этому провайдеру без вашего запроса.", "compactionRouting.providerWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", - "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо пробует свои цели ({providers}) по порядку и использует первую ответившую, поэтому разговор может получить любая из них.", + "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо выбирает одну из целей ({providers}) по своей стратегии маршрутизации, но после сбоя, допускающего повторную попытку, может повторить тот же запрос с полным содержимым разговора на другой цели, поэтому разговор может получить одна или несколько целей.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", "compactionRouting.providerWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога в {provider} для составления сводки; остальные запросы используют свою модель.", - "compactionRouting.comboWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога комбо {combo} для составления сводки; остальные запросы используют свою модель. Комбо выбирает одну из целей ({providers}) по своей стратегии маршрутизации, поэтому любая из них может получить подходящие запросы.", + "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 e575563cb1e..8987df6d16a 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -436,10 +436,10 @@ export const tr: Record = { "compactionRouting.dataNotice": "Kapsama giren bir özetleme isteği, konuşma başka bir sağlayıcıda yürütülse bile konuşmanın tamamını özetlenmek üzere seçilen modelin sağlayıcısına gönderir.", "compactionRouting.autoNotice": "Otomatik özetleme kendiliğinden çalışır; bu nedenle uzun bir konuşma siz istemeden o sağlayıcıya gidebilir.", "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.comboWarning": "Bu ayarla kapsama giren her özetleme isteği, konuşmanın tüm içeriğini özetlenmek üzere {combo} kombosuna gönderir. Kombo, yönlendirme stratejisine göre hedeflerinden ({providers}) birini seçer, ancak yeniden denenebilir bir hata sonrasında aynı tam konuşma isteğini başka bir hedefte yeniden deneyebilir; dolayısıyla bu hedeflerden biri veya birden fazlası konuşmayı alabilir.", "compactionRouting.comboProvidersUnknown": "yapılandırılmış hedef sağlayıcıları", "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, yönlendirme stratejisine göre hedeflerinden ({providers}) birini seçer; dolayısıyla bunlardan herhangi biri eşleşen istekleri alabilir.", + "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, yönlendirme stratejisine göre hedeflerinden ({providers}) birini seçer, ancak yeniden denenebilir bir hata sonrasında aynı tam konuşma isteğini başka bir hedefte yeniden deneyebilir; dolayısıyla bu hedeflerden biri veya birden fazlası konuşmayı 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 3d8135e6198..653d9f7d074 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -434,10 +434,10 @@ export const vi: Record = { "compactionRouting.dataNotice": "Yêu cầu nén thuộc phạm vi áp dụng sẽ gửi toàn bộ cuộc trò chuyện đến nhà cung cấp của model đã chọn để tóm tắt, ngay cả khi cuộc trò chuyện đang chạy ở nhà cung cấp khác.", "compactionRouting.autoNotice": "Nén tự động tự chạy, nên một cuộc trò chuyện dài có thể được gửi đến nhà cung cấp đó mà bạn không yêu cầu.", "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.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 chọn một trong các đích ({providers}) theo chiến lược định tuyến của nó, nhưng sau lỗi có thể thử lại, nó có thể thử lại cùng một yêu cầu chứa toàn bộ cuộc trò chuyện ở một đích khác, nên một hoặc nhiều đích 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 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 chọn một trong các đích ({providers}) theo chiến lược định tuyến của nó, nên bất kỳ đích nào cũng có thể nhận yêu cầu khớp.", + "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 chọn một trong các đích ({providers}) theo chiến lược định tuyến của nó, nhưng sau lỗi có thể thử lại, nó có thể thử lại cùng một yêu cầu chứa toàn bộ cuộc trò chuyện ở một đích khác, nên một hoặc nhiều đích có thể nhận 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 97cde3e3f25..0cf4cb33267 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -312,10 +312,10 @@ export const zhTW: Record = { "compactionRouting.dataNotice": "涵蓋的壓縮請求會將整個對話傳送給所選模型的供應商進行摘要,即使對話正在其他供應商上執行。", "compactionRouting.autoNotice": "自動壓縮會自行觸發,因此長對話可能在你沒有主動要求的情況下被傳送給該供應商。", "compactionRouting.providerWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給 {provider} 進行摘要。", - "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到該對話。", + "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依其路由策略選擇其中一個目標({providers}),但在發生可重試失敗後,可能於其他目標重試相同的完整對話請求,因此其中一個或多個目標都可能收到該對話。", "compactionRouting.comboProvidersUnknown": "其已設定的目標供應商", "compactionRouting.providerWarningScoped": "啟用此設定後,僅當壓縮請求的來源模型符合 {sources} 時,完整對話內容才會傳送給 {provider} 進行摘要;其他請求仍使用自身模型壓縮。", - "compactionRouting.comboWarningScoped": "啟用此設定後,僅當壓縮請求的來源模型符合 {sources} 時,完整對話內容才會傳送給組合 {combo} 進行摘要;其他請求仍使用自身模型壓縮。該組合會依其路由策略選擇其中一個目標({providers}),因此其中任一個都可能收到符合條件的請求。", + "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 36fb751ef3e..4d424a5cdcf 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -430,10 +430,10 @@ export const zh: Record = { "compactionRouting.dataNotice": "被覆盖的压缩请求会将整个对话发送给所选模型的提供商进行摘要,即使对话正在其他提供商上运行。", "compactionRouting.autoNotice": "自动压缩会自行触发,因此长对话可能在你没有主动要求的情况下被发送给该提供商。", "compactionRouting.providerWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给 {provider} 进行摘要。", - "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到该对话。", + "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按其路由策略选择其中一个目标({providers}),但在发生可重试失败后,可能在其他目标重试相同的完整对话请求,因此其中一个或多个目标都可能收到该对话。", "compactionRouting.comboProvidersUnknown": "其已配置的目标提供商", "compactionRouting.providerWarningScoped": "启用此设置后,仅当压缩请求的来源模型匹配 {sources} 时,完整对话内容才会发送给 {provider} 进行摘要;其他请求仍使用自身模型压缩。", - "compactionRouting.comboWarningScoped": "启用此设置后,仅当压缩请求的来源模型匹配 {sources} 时,完整对话内容才会发送给组合 {combo} 进行摘要;其他请求仍使用自身模型压缩。该组合会按其路由策略选择其中一个目标({providers}),因此其中任意一个都可能收到匹配的请求。", + "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 fd4998fe29c..b6ec538b4a6 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -241,7 +241,7 @@ test("offers a provider-wide selector for a configured provider with no catalog expect(sourceCheckbox("lonely/*")).toBeDefined(); }); -test("the scoped combo disclosure stays strategy-neutral", async () => { +test("the scoped combo disclosure covers retry to multiple targets", async () => { await render(); await choose("model", "gateway/cheap"); await choose("sources", "Selected sources only"); @@ -253,6 +253,9 @@ test("the scoped combo disclosure stays strategy-neutral", async () => { // promise configured order or first-answer selection. expect(comboNote).not.toContain("in order"); expect(comboNote).toContain("routing strategy"); + expect(comboNote).toContain("retryable failure"); + expect(comboNote).toContain("same full-conversation request"); + expect(comboNote).toContain("one or more target providers"); }); test("failed save retains the draft and allows retry", async () => { @@ -296,9 +299,13 @@ test("discloses that the selected provider receives the full conversation", asyn const comboNote = container.querySelector('[role="note"]')?.textContent ?? ""; expect(comboNote).toContain("combo combo/compact"); expect(comboNote).toContain("(gateway, openai-apikey)"); - // The runtime tries one target at a time and stops at the first answer; the panel used to - // promise fan-out to every target, which an operator would budget latency and cost for. - expect(comboNote).toContain("in order and uses the first that answers"); + // The combo selects a target by its routing strategy, and after a retryable failure it + // may retry the same full-conversation request on another target, so the note must disclose + // that one or more targets can receive the conversation without assuming fixed target order. + expect(comboNote).not.toContain("in order"); + expect(comboNote).toContain("routing strategy"); + expect(comboNote).toContain("after a retryable failure"); + expect(comboNote).toContain("one or more target providers can receive the conversation"); expect(comboNote).not.toContain("every target"); await choose("model", "Use conversation model"); expect(container.querySelector('[role="note"]')).toBeNull(); @@ -313,7 +320,7 @@ test("names the targets of a combo reached through an alias", async () => { expect(note).toContain(`combo ${ALIASED_COMBO.model}`); expect(note).toContain("(xai, gateway)"); expect(note).not.toContain("its configured target providers"); - expect(note).toContain("in order and uses the first that answers"); + expect(note).toContain("one or more target providers can receive the conversation"); }); test("still calls a prefixed combo a combo when the combo list is unavailable", async () => { From 237b1f5ae17c9d96e9e746bc913aaa60a0595ac7 Mon Sep 17 00:00:00 2001 From: panyuanyuan Date: Tue, 29 Sep 2026 07:15:59 +0800 Subject: [PATCH 5/5] fix(gui): clarify Japanese and Russian compaction disclosures --- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ru.ts | 4 ++-- gui/tests/compaction-routing-panel.test.tsx | 23 +++++++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 434fc619a33..201fb74ed43 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -438,7 +438,7 @@ export const ja: Record = { "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはルーティング戦略に従ってターゲット({providers})のいずれかを選びますが、再試行可能な失敗の後には同じ会話全内容のリクエストを別のターゲットで再試行する可能性があるため、ターゲットのうち 1 つ以上が会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", "compactionRouting.providerWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のため {provider} へ送信します。一致しないリクエストは元のモデルを使います。", - "compactionRouting.comboWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のためコンボ {combo} へ送信します。一致しないリクエストは元のモデルを使います。コンボはルーティング戦略に従ってターゲット({providers})のいずれかを選びますが、再試行可能な失敗の後には同じ会話全内容のリクエストを別のターゲットで再試行するため、ターゲットのうち 1 つ以上が会話を受け取る可能性があります。", + "compactionRouting.comboWarningScoped": "この設定では、元モデルが {sources} に一致する圧縮リクエストだけが会話の全内容を要約のためコンボ {combo} へ送信します。一致しないリクエストは元のモデルを使います。コンボはルーティング戦略に従ってターゲット({providers})のいずれかを選びますが、再試行可能な失敗の後には同じ会話全内容のリクエストを別のターゲットで再試行する可能性があるため、ターゲットのうち 1 つ以上が会話を受け取る可能性があります。", "compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。", "compactionRouting.saved": "圧縮設定を保存しました。", "compactionRouting.saveFailed": "保存できませんでした。変更内容は保持されています。再試行してください。", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index ebcbfe8d93c..aa335180103 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -435,10 +435,10 @@ export const ru: Record = { "compactionRouting.dataNotice": "Охваченный запрос сжатия отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", "compactionRouting.autoNotice": "Автоматическое сжатие запускается само, поэтому длинный разговор может уйти этому провайдеру без вашего запроса.", "compactionRouting.providerWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", - "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо выбирает одну из целей ({providers}) по своей стратегии маршрутизации, но после сбоя, допускающего повторную попытку, может повторить тот же запрос с полным содержимым разговора на другой цели, поэтому разговор может получить одна или несколько целей.", + "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо выбирает одну из целей ({providers}) по своей стратегии маршрутизации, но после сбоя, допускающего повторную попытку, может повторить тот же запрос с полным содержимым разговора на другой цели, поэтому одна или несколько целей могут получить полное содержимое разговора.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", "compactionRouting.providerWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога в {provider} для составления сводки; остальные запросы используют свою модель.", - "compactionRouting.comboWarningScoped": "При этой настройке только запросы сжатия, исходная модель которых совпадает с {sources}, отправляют полное содержимое диалога комбо {combo} для составления сводки; остальные запросы используют свою модель. Комбо выбирает одну из целей ({providers}) по своей стратегии маршрутизации, но после повторяемого сбоя может повторить тот же запрос с полным содержимым разговора на другой цели, поэтому разговор может получить одна или несколько целей.", + "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 b6ec538b4a6..bea4f284b24 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -258,6 +258,29 @@ test("the scoped combo disclosure covers retry to multiple targets", async () => expect(comboNote).toContain("one or more target providers"); }); +test("Japanese scoped disclosure describes retry as a possibility", async () => { + win.localStorage.setItem("ocx-lang", "ja"); + setting = { model: "combo/compact", sourceModels: ["gateway/*"] }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("gateway/*"); + expect(note).toContain("別のターゲットで再試行する可能性があるため"); + expect(note).not.toContain("別のターゲットで再試行するため"); +}); + +for (const scoped of [false, true]) { + test(`Russian ${scoped ? "scoped" : "unscoped"} disclosure identifies targets as conversation recipients`, async () => { + win.localStorage.setItem("ocx-lang", "ru"); + setting = { model: "combo/compact", ...(scoped ? { sourceModels: ["gateway/*"] } : {}) }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("combo/compact"); + if (scoped) expect(note).toContain("gateway/*"); + expect(note).toContain("одна или несколько целей могут получить полное содержимое разговора"); + expect(note).not.toContain("разговор может получить"); + }); +} + test("failed save retains the draft and allows retry", async () => { await render(); await choose("model", "gateway/cheap");