From e1347183dc9d299e4ea2f2be0f29b60f78b81648 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:04:15 +0900 Subject: [PATCH 1/6] fix(gui): resolve combos by what they are, and describe failover as it runs Two user-visible strings on the compaction-routing surface described behaviour the code does not have. The panel decided whether a selection was a combo by testing for a "combo/" prefix. A combo reached through an alias carries no prefix, so it was described as an ordinary provider and none of its targets were named -- the answer existed and the operator could not see it. The panel now asks what the selection resolves to: the combo list is keyed by the public model id the server already computes, which is the alias when one is set and "combo/" otherwise, so both spellings answer the same way. It reads that list through parseComboList, the same reader the combo workspace uses, so the selector rule is not spelled out a second time here. The combo warning told the operator that a covered compaction goes to every target, including failover targets. It does not. core-combo.ts dispatches one target per loop iteration, returns as soon as one responds, and advances only after a retryable failure. An operator reading the old text would budget fan-out cost and fan-out latency for something that never happens. The warning now says the targets are attempted in order and the first that answers is used, which is both what happens and what someone debugging a slow compaction needs. Wording changed in all ten locales. The regression covers the aliased combo the prefix test could not see, and asserts the ordering sentence rather than the fan-out claim. Closes #5216 --- gui/src/components/CompactionRoutingPanel.tsx | 33 +++++++++++-------- gui/src/i18n/de.ts | 2 +- gui/src/i18n/en.ts | 2 +- gui/src/i18n/fr.ts | 2 +- gui/src/i18n/ja.ts | 2 +- gui/src/i18n/ko.ts | 2 +- gui/src/i18n/ru.ts | 2 +- gui/src/i18n/tr.ts | 2 +- gui/src/i18n/vi.ts | 2 +- gui/src/i18n/zh-TW.ts | 2 +- gui/src/i18n/zh.ts | 2 +- gui/tests/compaction-routing-panel.test.tsx | 28 ++++++++++++++-- 12 files changed, 55 insertions(+), 26 deletions(-) diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index e2285891447..158860fe1b9 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -4,6 +4,7 @@ import { IconAlert } from "../icons"; import { Select } from "../ui"; import { createBoundedFetch } from "../bounded-fetch"; import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; +import { parseComboList } from "../combo-workspace-data"; import { formatNamespacedModelId } from "../provider-icons"; type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null; @@ -32,17 +33,19 @@ function choiceToTriggers(choice: string): string[] | undefined { return undefined; } +/** + * Target providers keyed by the selector a client actually requests. + * + * Keyed by the combo's public model id rather than its raw id, because a combo reached through + * an alias carries no `combo/` prefix: the panel used to test for that prefix, fail to + * recognize an aliased combo, and describe it as an ordinary provider while naming none of its + * targets (#5216). `parseComboList` is the same reader the combo workspace uses, so the + * selector rule lives in one place instead of being spelled out again here. + */ function readComboProviders(payload: unknown): Record { - const combos = (payload as { combos?: unknown })?.combos; - if (!Array.isArray(combos)) return {}; const result: Record = {}; - for (const combo of combos) { - if (!combo || typeof combo !== "object" || typeof (combo as { id?: unknown }).id !== "string") continue; - const targets = (combo as { targets?: unknown }).targets; - const providers = Array.isArray(targets) - ? targets.map(target => (target as { provider?: unknown })?.provider).filter((value): value is string => typeof value === "string") - : []; - result[(combo as { id: string }).id] = [...new Set(providers)]; + for (const combo of parseComboList(payload)) { + result[combo.model] = [...new Set(combo.targets.map(target => target.provider).filter(Boolean))]; } return result; } @@ -163,10 +166,12 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const dirty = model !== (saved?.model ?? "") || effort !== (saved?.reasoningEffort ?? "") || triggers !== triggersToChoice(saved?.triggers); + // 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[model]; const namespace = model.slice(0, Math.max(model.indexOf("/"), 0)); - const combo = namespace === "combo" ? model.slice(namespace.length + 1) : ""; - const provider = namespace && !combo ? namespace : model; - const providers = comboProviders[combo]?.join(", ") || t("compactionRouting.comboProvidersUnknown"); + const provider = comboTargets ? "" : (namespace || model); + const providers = comboTargets?.join(", ") || t("compactionRouting.comboProvidersUnknown"); const routesAutomatic = triggers !== "manual"; return ( @@ -195,10 +200,10 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model - {provider &&
{combo + {model &&
{comboTargets ? t("compactionRouting.comboWarning", { combo: model, providers }) : t("compactionRouting.providerWarning", { provider })}
} - {provider && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} + {model && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} {loadError &&
{t("compactionRouting.loadFailed")}
} {feedback === "failed" &&
{t("compactionRouting.saveFailed")}
} {feedback === "saved" &&
{t("compactionRouting.saved")}
} diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1712170d5bf..bb936060c12 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -366,7 +366,7 @@ 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 jedes Ziel der Combo {combo} ({providers}), einschließlich Failover-Zielen.", + "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.loadFailed": "Komprimierungseinstellungen konnten nicht geladen werden.", "compactionRouting.saved": "Komprimierungseinstellungen gespeichert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index ab3a7dc8d6b..93af425073b 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -384,7 +384,7 @@ 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 every target of combo {combo} ({providers}), including failover targets, 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.loadFailed": "Could not load compaction settings.", "compactionRouting.saved": "Compaction settings saved.", diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1cc6e4e5265..b81652bacd2 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -374,7 +374,7 @@ 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 à chaque cible du combo {combo} ({providers}), y compris les cibles de bascule, pour le 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.loadFailed": "Impossible de charger les paramètres de compaction.", "compactionRouting.saved": "Paramètres de compaction enregistrés.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index b61caac4a61..7565f3c2d9a 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -375,7 +375,7 @@ export const ja: Record = { "compactionRouting.dataNotice": "適用対象の圧縮リクエストは、会話が別のプロバイダーで動いていても、会話全体を選択したモデルのプロバイダーへ送信して要約します。", "compactionRouting.autoNotice": "自動圧縮はユーザーが求めなくても実行されるため、長い会話が知らないうちにそのプロバイダーへ送信されることがあります。", "compactionRouting.providerWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のために {provider} へ送信されます。", - "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が、フェイルオーバー先を含むコンボ {combo} のすべてのターゲット({providers})へ要約のために送信されます。", + "compactionRouting.comboWarning": "この設定では、適用対象の圧縮リクエストのたびに会話の全内容が要約のためコンボ {combo} へ送信されます。コンボはターゲット({providers})を順に試し、最初に応答したものを使うため、そのいずれもが会話を受け取る可能性があります。", "compactionRouting.comboProvidersUnknown": "設定済みのターゲットプロバイダー", "compactionRouting.loadFailed": "圧縮設定を読み込めませんでした。", "compactionRouting.saved": "圧縮設定を保存しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 68b1a561a65..94bd6fcab74 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -370,7 +370,7 @@ export const ko: Record = { "compactionRouting.dataNotice": "적용 대상 압축 요청은 대화가 다른 프로바이더에서 실행 중이더라도 전체 대화를 선택한 모델의 프로바이더로 보내 요약합니다.", "compactionRouting.autoNotice": "자동 압축은 사용자가 요청하지 않아도 실행되므로, 긴 대화가 예고 없이 해당 프로바이더로 전송될 수 있습니다.", "compactionRouting.providerWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 {provider}로 전송됩니다.", - "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 장애 조치 대상을 포함한 콤보 {combo}의 모든 대상({providers})으로 요약을 위해 전송됩니다.", + "compactionRouting.comboWarning": "이 설정을 사용하면 적용 대상 압축 요청마다 전체 대화 내용이 요약을 위해 콤보 {combo}로 전송됩니다. 콤보는 대상({providers})을 순서대로 시도해 먼저 응답한 하나를 사용하므로, 그중 어느 것이든 대화를 받을 수 있습니다.", "compactionRouting.comboProvidersUnknown": "구성된 대상 프로바이더", "compactionRouting.loadFailed": "압축 설정을 불러올 수 없습니다.", "compactionRouting.saved": "압축 설정을 저장했습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 440958324df..bf7c4fcd783 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -375,7 +375,7 @@ export const ru: Record = { "compactionRouting.dataNotice": "Охваченный запрос сжатия отправляет весь разговор провайдеру выбранной модели для составления сводки, даже если разговор идёт у другого провайдера.", "compactionRouting.autoNotice": "Автоматическое сжатие запускается само, поэтому длинный разговор может уйти этому провайдеру без вашего запроса.", "compactionRouting.providerWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора провайдеру {provider} для составления сводки.", - "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора каждой цели комбо {combo} ({providers}), включая резервные цели, для составления сводки.", + "compactionRouting.comboWarning": "С этой настройкой каждый охваченный запрос сжатия отправляет полное содержимое разговора комбо {combo} для составления сводки. Комбо пробует свои цели ({providers}) по порядку и использует первую ответившую, поэтому разговор может получить любая из них.", "compactionRouting.comboProvidersUnknown": "его настроенные целевые провайдеры", "compactionRouting.loadFailed": "Не удалось загрузить настройки сжатия.", "compactionRouting.saved": "Настройки сжатия сохранены.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 0707038237e..eb989bb3ba2 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -376,7 +376,7 @@ 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 yedek hedefler dahil {combo} kombosunun her hedefine ({providers}) özetlenmek üzere 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.loadFailed": "Özetleme ayarları yüklenemedi.", "compactionRouting.saved": "Özetleme ayarları kaydedildi.", diff --git a/gui/src/i18n/vi.ts b/gui/src/i18n/vi.ts index 9af7198a8be..d5b6a149f53 100644 --- a/gui/src/i18n/vi.ts +++ b/gui/src/i18n/vi.ts @@ -368,7 +368,7 @@ 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 mọi đích của combo {combo} ({providers}), bao gồm cả đích dự phòng, để 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.loadFailed": "Không thể tải cài đặt nén.", "compactionRouting.saved": "Đã lưu cài đặt nén.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 01799d6b8b1..fd1f07dd55e 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -265,7 +265,7 @@ export const zhTW: Record = { "compactionRouting.dataNotice": "涵蓋的壓縮請求會將整個對話傳送給所選模型的供應商進行摘要,即使對話正在其他供應商上執行。", "compactionRouting.autoNotice": "自動壓縮會自行觸發,因此長對話可能在你沒有主動要求的情況下被傳送給該供應商。", "compactionRouting.providerWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給 {provider} 進行摘要。", - "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 的每個目標({providers}),包括容錯移轉目標,以進行摘要。", + "compactionRouting.comboWarning": "啟用此設定後,每個涵蓋的壓縮請求都會將完整對話內容傳送給組合 {combo} 以進行摘要。該組合會依序嘗試其目標({providers}),並使用第一個回應的目標,因此其中任一個都可能收到該對話。", "compactionRouting.comboProvidersUnknown": "其已設定的目標供應商", "compactionRouting.loadFailed": "無法載入壓縮設定。", "compactionRouting.saved": "壓縮設定已儲存。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0f0e06ca155..3189179e3fe 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -370,7 +370,7 @@ export const zh: Record = { "compactionRouting.dataNotice": "被覆盖的压缩请求会将整个对话发送给所选模型的提供商进行摘要,即使对话正在其他提供商上运行。", "compactionRouting.autoNotice": "自动压缩会自行触发,因此长对话可能在你没有主动要求的情况下被发送给该提供商。", "compactionRouting.providerWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给 {provider} 进行摘要。", - "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 的每个目标({providers}),包括故障转移目标,以进行摘要。", + "compactionRouting.comboWarning": "启用此设置后,每个被覆盖的压缩请求都会将完整对话内容发送给组合 {combo} 以进行摘要。该组合会按顺序尝试其目标({providers}),并使用第一个响应的目标,因此其中任意一个都可能收到该对话。", "compactionRouting.comboProvidersUnknown": "其已配置的目标提供商", "compactionRouting.loadFailed": "无法加载压缩设置。", "compactionRouting.saved": "压缩设置已保存。", diff --git a/gui/tests/compaction-routing-panel.test.tsx b/gui/tests/compaction-routing-panel.test.tsx index 9e698c02bb6..bbd7ebefb1d 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -16,6 +16,9 @@ let failLoad: boolean; let failSave: boolean; let writes: unknown[]; const models = [{ id: "cheap", provider: "gateway", namespaced: "gateway/cheap" }, { id: "compact", provider: "combo", namespaced: "combo/compact" }]; +// A combo reached through an alias carries no `combo/` prefix, which is the shape #5216 was +// filed about: the panel has to learn what the selection resolves to, not read its name. +const ALIASED_COMBO = { id: "fast", model: "quickpick", alias: "quickpick", targets: [{ provider: "xai", model: "a" }, { provider: "gateway", model: "b" }] }; beforeEach(() => { previous = Object.fromEntries(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); @@ -28,7 +31,10 @@ beforeEach(() => { setting = null; failLoad = false; failSave = false; writes = []; Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => { if (String(_input).endsWith("/api/combos")) { - return Response.json({ combos: [{ id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }] }); + return Response.json({ combos: [ + { id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }, + ALIASED_COMBO, + ] }); } if (init?.method === "PUT") { const body = JSON.parse(String(init.body)); @@ -148,7 +154,25 @@ test("discloses that the selected provider receives the full conversation", asyn await choose("model", "gateway/cheap"); expect(container.querySelector('[role="note"]')?.textContent).toContain("sends the full conversation contents to gateway for summarization"); await choose("model", "combo/compact"); - expect(container.querySelector('[role="note"]')?.textContent).toContain("every target of combo combo/compact (gateway, openai-apikey), including failover targets"); + 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"); + expect(comboNote).not.toContain("every target"); await choose("model", "Use conversation model"); expect(container.querySelector('[role="note"]')).toBeNull(); }); + +test("names the targets of a combo reached through an alias", async () => { + setting = { model: ALIASED_COMBO.model }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + // Before #5216 this selection had no `combo/` prefix, so the panel called it a provider and + // named none of its targets. + 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"); +}); From eed0c72678e3e5545dc7a4148b1236c7775aa2f5 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:07:09 +0900 Subject: [PATCH 2/6] docs: check the provider discovery limits against the registry The provider guides restate a byte ceiling and a row ceiling for thirteen fixed-host presets, in eight pages, and nothing compared any copy to the registry. #5198 fixed a preset count that had drifted across sixteen files for months for exactly that reason; these limits are the same shape one layer down. Every number is now read from that preset's modelDiscovery and asserted against every shipped guide, so lowering a ceiling fails in all eight locales at once instead of leaving seven translations describing the old one. A grouped section must first agree in the registry before one sentence may speak for two presets, which is what makes the Nscale/Vultr and Command Code sentences legitimate rather than convenient. Sections are located by brand name and the presence of a KiB or MiB token, not by a translated sentence. A restated anchor phrase is the same hand-copied value the guard exists to remove, and the brand names are Latin in all eight published locales. The byte ceiling is compared as an exact token set rather than a substring, so a stale number left beside the current one fails. The structure record claimed the guides carried identical limits. That claim was false when it was written: the Korean guide had no Featherless section, so it documented twelve of the thirteen limited presets. The section is added and the prose is replaced by a description of what is actually asserted. Closes #5215 --- .../src/content/docs/ko/guides/providers.md | 9 ++ scripts/test-layout/layout.json | 3 +- structure/ops/docs-and-release.md | 15 ++- .../docs-provider-discovery-limits.test.ts | 123 ++++++++++++++++++ tests/fixtures/test-layout-expected.json | 3 +- 5 files changed, 150 insertions(+), 3 deletions(-) create mode 100644 tests/ci-workflows/docs-provider-discovery-limits.test.ts diff --git a/docs-site/src/content/docs/ko/guides/providers.md b/docs-site/src/content/docs/ko/guides/providers.md index 9e8b372e967..cf423679604 100644 --- a/docs-site/src/content/docs/ko/guides/providers.md +++ b/docs-site/src/content/docs/ko/guides/providers.md @@ -345,6 +345,15 @@ discovery를 256 KiB와 raw 행 256개로 제한합니다. agent 전용 및 dedi Project ID가 포함된 URL과 dedicated deployment는 custom provider로 설정하세요. API 키는 [Scaleway console](https://console.scaleway.com/generative-api)에서 생성합니다. +**Featherless 검색:** 이 프리셋은 고정된 OpenAI 호환 host에 인증하고, 상위에서 chat과 현재 plan으로 +필터링된 인기 모델 100개만 요청합니다. 이후 registry 규칙은 각 행이 plan 사용 가능 여부, Hugging Face +gate 없음, `features.tool_use: true`를 스스로 보고하지 않으면 fail closed로 제외하고, discovery를 +128 KiB와 raw 행 100개로 제한합니다. 덕분에 수만 개 규모의 catalog를 통째로 내려받거나 캐시하지 +않습니다. `/v1/models`는 인증 없이도 호출할 수 있다고 문서화되어 있어 전달한 키가 유효한지 증명하지 +못합니다. chat 요청에는 설정된 Bearer key를 그대로 사용합니다. Featherless 약관은 개인 plan을 대화형 +및 프로토타이핑 용도로 제한하며, 임의의 애플리케이션에는 Scale plan이 필요합니다. 키는 +[Featherless dashboard](https://featherless.ai/account/api-keys)에서 생성합니다. + **Novita 검색:** 키 기반 프리셋은 `openai-chat` adapter를 사용하며 Bearer key를 Novita의 고정 OpenAI 호환 host에만 보냅니다. 공개 model list에서 `model_type: chat`과 `chat/completions` endpoint를 모두 보고하는 행만 유지하고 discovery를 512 KiB와 raw 256행으로 제한합니다. catalog가 공개되어 있으므로 diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 7bbb92e15db..3077ab71c49 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -1580,7 +1580,8 @@ "openai-chat-developer-position.test.ts": "adapters/openai", "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", - "api-key-model-scope.test.ts": "server" + "api-key-model-scope.test.ts": "server", + "docs-provider-discovery-limits.test.ts": "ci-workflows" }, "migrated": [ "adapters", diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 89fe8db595a..6093bcc7961 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -48,7 +48,7 @@ sidebar and either add localized copies or intentionally accept Starlight fallba Provider preset totals are recounted from the current registry when a preset lands. The documented split is 95 total: 79 key-based, 12 OAuth, three local, and one default ChatGPT-forward preset. The English provider guide, all seven translated copies, and all eight -quickstarts carry the same counts, and the guides carry the same fixed-host discovery limits. +quickstarts carry the same counts. That recount is no longer a manual obligation. Seventeen places restate these numbers and sixteen of them drifted once already — the English guide reached 95 while every translation and every @@ -58,6 +58,19 @@ key-based split from `PROVIDER_REGISTRY` and asserts them against each page, so fails every locale at once instead of drifting. Each page is located by a locale-specific phrase rather than by its number, so rewording a sentence fails the check and asks to be re-anchored. +The fixed-host discovery limits are the same shape one layer down, and this document used to +assert their parity in prose: it claimed the guides carried the same limits, across sixteen-plus +files, verified by nobody. That claim was false when it was written — the Korean guide had no +Featherless section at all, so it documented twelve of the thirteen limited presets. +`tests/ci-workflows/docs-provider-discovery-limits.test.ts` replaces the claim with the check: +each section's byte and row ceilings are read from that preset's `modelDiscovery` and asserted +against every shipped guide, and a grouped section must first agree in the registry before one +sentence may describe both presets. Sections are located by brand name and the presence of a +`KiB`/`MiB` token rather than by a translated phrase, because a restated anchor is the same +hand-copied value the guard exists to remove; a section that is missing or duplicated fails by +name. The byte ceiling is compared as an exact token set, so a stale number left beside the +current one fails instead of passing on a substring. + Native retirement keeps active model/quota instructions aligned across locales with the [catalog contract](../catalog.md#shared-catalog). Historical records and other providers sharing a model-name fragment remain distinct from current Codex-native support. diff --git a/tests/ci-workflows/docs-provider-discovery-limits.test.ts b/tests/ci-workflows/docs-provider-discovery-limits.test.ts new file mode 100644 index 00000000000..2c28607c28a --- /dev/null +++ b/tests/ci-workflows/docs-provider-discovery-limits.test.ts @@ -0,0 +1,123 @@ +/** + * The fixed-host discovery limits in the provider guides must be read back from the registry. + * + * #5198 corrected a preset count that had drifted across sixteen files, and the interesting part + * was how long it survived: nothing compared any copy to the registry, so CI stayed green the + * whole time. The per-provider discovery limits are the same shape one layer down -- a byte + * ceiling and a row ceiling, restated by hand in eight pages, checked by nobody (#5215). + * + * Every number here is derived from PROVIDER_REGISTRY, so lowering a ceiling fails in every + * locale at once instead of leaving seven translations describing the old one. + * + * Sections are located by the brand name and the presence of a unit token, not by a translated + * sentence. A restated anchor phrase is the same hand-copied value this test exists to remove, + * and the brand names are Latin in all eight published locales. A page where the section is + * missing or appears twice fails by name and asks to be re-anchored; that is how the Korean + * guide's absent Featherless section was found. + */ +import { describe, expect, test } from "bun:test"; +import { PROVIDER_REGISTRY } from "../../src/providers/registry"; +import { repoPath } from "../helpers/repo-root"; + +/** English plus every translated guide that ships, matching docs-site/astro.config.mjs. */ +const GUIDES = [ + { locale: "en", path: "docs-site/src/content/docs/guides/providers.md" }, + { locale: "fr", path: "docs-site/src/content/docs/fr/guides/providers.md" }, + { locale: "ja", path: "docs-site/src/content/docs/ja/guides/providers.md" }, + { locale: "ko", path: "docs-site/src/content/docs/ko/guides/providers.md" }, + { locale: "ru", path: "docs-site/src/content/docs/ru/guides/providers.md" }, + { locale: "tr", path: "docs-site/src/content/docs/tr/guides/providers.md" }, + { locale: "zh-cn", path: "docs-site/src/content/docs/zh-cn/guides/providers.md" }, + { locale: "zh-tw", path: "docs-site/src/content/docs/zh-tw/guides/providers.md" }, +] as const; + +/** + * One documented section per entry. `ids` holds every registry preset the section speaks for; + * a grouped section must agree in the registry before one sentence can describe both. + */ +const SECTIONS = [ + { brand: "Chutes", ids: ["chutes"] }, + { brand: "DeepInfra", ids: ["deepinfra"] }, + { brand: "Hyperbolic", ids: ["hyperbolic"] }, + { brand: "Nscale", ids: ["nscale", "vultr"] }, + { brand: "Command Code", ids: ["command-code", "commandcode"] }, + { brand: "SambaNova", ids: ["sambanova"] }, + { brand: "Nebius", ids: ["nebius"] }, + { brand: "Crusoe", ids: ["crusoe"] }, + { brand: "DigitalOcean", ids: ["digitalocean"] }, + { brand: "Scaleway", ids: ["scaleway"] }, + { brand: "Featherless", ids: ["featherless"] }, + { brand: "Novita", ids: ["novita"] }, + { brand: "Baseten", ids: ["baseten"] }, +] as const; + +const UNIT_TOKEN = /(\d+(?:[.,]\d+)?)\s*(KiB|MiB)/g; + +interface Limits { bytes: number; rows: number } + +function registryLimits(id: string): Limits { + const entry = PROVIDER_REGISTRY.find(row => row.id === id); + expect(entry, `no registry preset with id "${id}"; re-anchor this check`).toBeDefined(); + const discovery = entry!.modelDiscovery; + expect(discovery, `preset "${id}" declares no modelDiscovery`).toBeDefined(); + const { maxResponseBytes, maxModels } = discovery!; + expect(typeof maxResponseBytes, `preset "${id}" declares no maxResponseBytes`).toBe("number"); + expect(typeof maxModels, `preset "${id}" declares no maxModels`).toBe("number"); + return { bytes: maxResponseBytes!, rows: maxModels! }; +} + +/** The documented spelling of a byte ceiling: whole MiB where it divides, otherwise KiB. */ +function unitLabel(bytes: number): string { + const MIB = 1024 * 1024; + return bytes % MIB === 0 ? `${bytes / MIB} MiB` : `${bytes / 1024} KiB`; +} + +/** The paragraph that states this section's limits, located by brand plus a unit token. */ +async function limitParagraph(path: string, brand: string): Promise { + const source = await Bun.file(repoPath(path)).text(); + const paragraphs = source.split(/\n\s*\n/).filter(block => { + UNIT_TOKEN.lastIndex = 0; + return block.includes(brand) && UNIT_TOKEN.test(block); + }); + expect( + paragraphs.length, + `${path} should state the ${brand} discovery limits in exactly one paragraph; found ${paragraphs.length}`, + ).toBe(1); + return paragraphs[0]!; +} + +function unitTokens(paragraph: string): string[] { + UNIT_TOKEN.lastIndex = 0; + return [...paragraph.matchAll(UNIT_TOKEN)].map(match => `${match[1]} ${match[2]}`); +} + +describe("documented provider discovery limits match the registry", () => { + test("the registry is the only source of the numbers under test", () => { + expect(SECTIONS.length).toBeGreaterThan(0); + for (const section of SECTIONS) { + const limits = section.ids.map(registryLimits); + expect(limits[0]!.bytes, `${section.brand} byte ceiling`).toBeGreaterThan(0); + expect(limits[0]!.rows, `${section.brand} row ceiling`).toBeGreaterThan(0); + // A grouped section states one pair for several presets. If they ever diverge, the + // sentence is wrong for one of them and the group has to be split, not re-measured. + for (const other of limits.slice(1)) { + expect(other, `${section.ids.join(" and ")} must declare the same discovery limits`) + .toEqual(limits[0]!); + } + } + }); + + for (const guide of GUIDES) { + for (const section of SECTIONS) { + const { bytes, rows } = registryLimits(section.ids[0]!); + test(`${guide.locale} guide states ${unitLabel(bytes)} and ${rows} rows for ${section.brand}`, async () => { + const paragraph = await limitParagraph(guide.path, section.brand); + // An exact token set, not a substring: a stale ceiling left beside the current one + // would otherwise pass while still telling the reader the wrong number. + expect(unitTokens(paragraph), `${guide.path} ${section.brand} byte ceiling`) + .toEqual([unitLabel(bytes)]); + expect(paragraph, `${guide.path} ${section.brand} row ceiling`).toContain(String(rows)); + }); + } + } +}); diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 32ab9d258b1..a167909e93c 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1412,5 +1412,6 @@ "openai-chat-developer-position.test.ts": "adapters/openai", "chat-inline-document-bytes.test.ts": "responses", "devin-output-budget.test.ts": "providers", - "api-key-model-scope.test.ts": "server" + "api-key-model-scope.test.ts": "server", + "docs-provider-discovery-limits.test.ts": "ci-workflows" } From af33bc39d7975d758ac81fedca20c5be56a3229f Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:20:13 +0900 Subject: [PATCH 3/6] fix(gui,test): close three defects an adversarial pass found in this lane Combo target lookup read a plain object by the selected model id. A combo id is free-form, so an alias of "constructor" or "toString" resolved to an inherited Object member and the renderer tried to join a function. Read it with Object.hasOwn. Recognizing a combo only through the fetched list lost the canonical prefix as a signal of its own. When /api/combos has not answered yet or failed, a "combo/x" selection was described as an ordinary provider named "combo" -- worse than the alias gap this lane set out to fix, because that path is reachable whenever the management API is briefly unavailable. The prefix is kept as an independent signal and the target names fall back to the existing "its configured target providers" wording. The documentation guard compared the row ceiling as a substring of the whole paragraph, so the byte ceiling's own digits could satisfy it: a Hyperbolic paragraph saying "256 KiB and 128 raw rows" would have passed an expected 256 rows. Row numbers are now read from the prose with the unit tokens removed. All 104 locale/section combinations still pass, verified by transcribing the test's own logic over the eight guides. --- gui/src/components/CompactionRoutingPanel.tsx | 16 +++++++++---- gui/tests/compaction-routing-panel.test.tsx | 24 ++++++++++++++++++- .../docs-provider-discovery-limits.test.ts | 13 +++++++++- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index 158860fe1b9..96f42b6295a 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -4,7 +4,7 @@ import { IconAlert } from "../icons"; import { Select } from "../ui"; import { createBoundedFetch } from "../bounded-fetch"; import { requireJson, type ModelInfo } from "../pages/dashboard-shared"; -import { parseComboList } from "../combo-workspace-data"; +import { comboModelId, parseComboList } from "../combo-workspace-data"; import { formatNamespacedModelId } from "../provider-icons"; type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null; @@ -167,10 +167,16 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model || effort !== (saved?.reasoningEffort ?? "") || triggers !== triggersToChoice(saved?.triggers); // 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[model]; + // here exactly like a prefixed one (#5216). `Object.hasOwn` because a combo id is free-form: + // an alias of `constructor` or `toString` would otherwise read an inherited member and be + // joined as if it were a target list. + const comboTargets = Object.hasOwn(comboProviders, model) ? comboProviders[model] : undefined; + // The canonical prefix stays a combo signal of its own. It is the only one left when + // /api/combos has not answered yet or failed, and losing it there would describe a combo as + // 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 = comboTargets ? "" : (namespace || model); + const provider = isCombo ? "" : (namespace || model); const providers = comboTargets?.join(", ") || t("compactionRouting.comboProvidersUnknown"); const routesAutomatic = triggers !== "manual"; @@ -200,7 +206,7 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
- {model &&
{comboTargets + {model &&
{isCombo ? t("compactionRouting.comboWarning", { combo: model, providers }) : t("compactionRouting.providerWarning", { provider })}
} {model && routesAutomatic &&
{t("compactionRouting.autoNotice")}
} diff --git a/gui/tests/compaction-routing-panel.test.tsx b/gui/tests/compaction-routing-panel.test.tsx index bbd7ebefb1d..13fb2cde8d0 100644 --- a/gui/tests/compaction-routing-panel.test.tsx +++ b/gui/tests/compaction-routing-panel.test.tsx @@ -14,6 +14,7 @@ let container: HTMLDivElement; let setting: { model: string; reasoningEffort?: string; triggers?: string[] } | null; let failLoad: boolean; let failSave: boolean; +let combosUnavailable: boolean; let writes: unknown[]; const models = [{ id: "cheap", provider: "gateway", namespaced: "gateway/cheap" }, { id: "compact", provider: "combo", namespaced: "combo/compact" }]; // A combo reached through an alias carries no `combo/` prefix, which is the shape #5216 was @@ -28,9 +29,10 @@ beforeEach(() => { } Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true }); win.localStorage.setItem("ocx-lang", "en"); - setting = null; failLoad = false; failSave = false; writes = []; + setting = null; failLoad = false; failSave = false; combosUnavailable = false; writes = []; Object.defineProperty(globalThis, "fetch", { configurable: true, writable: true, value: async (_input: unknown, init?: RequestInit) => { if (String(_input).endsWith("/api/combos")) { + if (combosUnavailable) return Response.json({ error: "unavailable" }, { status: 503 }); return Response.json({ combos: [ { id: "compact", model: "combo/compact", targets: [{ provider: "gateway", model: "a" }, { provider: "openai-apikey", model: "b" }, { provider: "gateway", model: "c" }] }, ALIASED_COMBO, @@ -176,3 +178,23 @@ test("names the targets of a combo reached through an alias", async () => { expect(note).not.toContain("its configured target providers"); expect(note).toContain("in order and uses the first that answers"); }); + +test("still calls a prefixed combo a combo when the combo list is unavailable", async () => { + // The combo list is the only source of target names, and losing it must not downgrade the + // disclosure to "the provider named combo receives your conversation". + combosUnavailable = true; + setting = { model: "combo/compact" }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("combo combo/compact"); + expect(note).toContain("its configured target providers"); +}); + +test("an alias that shadows an Object member is not read as a target list", async () => { + // A combo id is free-form, so `constructor` is a legal alias. Reading it off a plain object + // would hand the renderer a function to join. + setting = { model: "constructor" }; + await render(); + const note = container.querySelector('[role="note"]')?.textContent ?? ""; + expect(note).toContain("sends the full conversation contents to constructor for summarization"); +}); diff --git a/tests/ci-workflows/docs-provider-discovery-limits.test.ts b/tests/ci-workflows/docs-provider-discovery-limits.test.ts index 2c28607c28a..ab160372431 100644 --- a/tests/ci-workflows/docs-provider-discovery-limits.test.ts +++ b/tests/ci-workflows/docs-provider-discovery-limits.test.ts @@ -91,6 +91,13 @@ function unitTokens(paragraph: string): string[] { return [...paragraph.matchAll(UNIT_TOKEN)].map(match => `${match[1]} ${match[2]}`); } +/** Every standalone integer left once the byte ceilings are removed. */ +function rowNumbers(paragraph: string): number[] { + UNIT_TOKEN.lastIndex = 0; + const withoutUnits = paragraph.replace(UNIT_TOKEN, " "); + return [...withoutUnits.matchAll(/(? Number(match[1])); +} + describe("documented provider discovery limits match the registry", () => { test("the registry is the only source of the numbers under test", () => { expect(SECTIONS.length).toBeGreaterThan(0); @@ -116,7 +123,11 @@ describe("documented provider discovery limits match the registry", () => { // would otherwise pass while still telling the reader the wrong number. expect(unitTokens(paragraph), `${guide.path} ${section.brand} byte ceiling`) .toEqual([unitLabel(bytes)]); - expect(paragraph, `${guide.path} ${section.brand} row ceiling`).toContain(String(rows)); + // Read the row ceiling from the prose with the unit tokens removed. Left in, the byte + // ceiling's own digits satisfy the check: a Hyperbolic paragraph saying "256 KiB and + // 128 raw rows" would pass an expected 256 rows on the strength of the byte number. + expect(rowNumbers(paragraph), `${guide.path} ${section.brand} row ceiling`) + .toContain(rows); }); } } From 5445e38a24997037ec84689a0640e7dbf884d7db Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:20:13 +0900 Subject: [PATCH 4/6] docs(devlog): record lane G onboarding, update and screen improvements Why the recovery path cannot live in the dashboard, what each of the six targets needed, the differential between the two workspace pull requests with the three findings that decide their sequencing, and the one src/ defect this lane identified and left stated rather than half-fixed. --- .../020_lane_g.md | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md diff --git a/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md b/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md new file mode 100644 index 00000000000..1d38c8531ef --- /dev/null +++ b/devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md @@ -0,0 +1,120 @@ +# Lane G — onboarding, update and screen improvements as one flow + +Status: OPEN. Branch `codex/260920-lane-g-onboarding-update`, cut from `origin/dev` +`043aa435ff`. One branch, ordered commits, one pull request against `dev`. + +Roadmap item 16 asks for connect → confirm → change → check state → recover as a single user +flow, built on the server-owned preview that landed in #5185 and #5197. Six targets were named: +#5016, #4560, #5068, #2811, #5215 and #5216. + +## The finding that shapes the flow + +The dashboard is served by the proxy. `startServer` binds the listener and that same listener +serves `gui/dist`, so when the proxy stops there is no surface left to render a recovery panel. +The state #5261 was reported in — injected routing pointing Codex's own built-in provider at a +dead loopback port — is therefore a state the dashboard cannot be part of getting out of. + +That is not a reason to leave the failure state undesigned. It moves where the design has to +land: + +- **Recovery belongs to the surfaces that survive the proxy.** #5267 already placed it there: + `ocx status` names `ocx restore` when the proxy is down and the routing is ours, the routing + marker in `config.toml` now reads `(undo: ocx restore)`, and the troubleshooting page covers + the manual edit for someone without the CLI. +- **The dashboard's job is disclosure before the fact.** It is the only surface present at the + moment the integration is applied, and it is the one that will be gone if the proxy later + stops. Naming the offline undo path at apply time is what turns a lockout into an + inconvenience. + +## What each target needed, and what this lane did + +### #5216 — compaction panel (delivered) + +Two strings described behaviour the code does not have. The panel decided combo-ness by testing +a `combo/` prefix, so a combo reached through an alias was described as an ordinary provider and +none of its targets were named. It now asks what the selection resolves to, keyed by the public +model id the server already computes, read through `parseComboList` — the same reader the combo +workspace uses, so the selector rule is not written down twice. + +The warning claimed a covered compaction goes to every target including failover targets. +`core-combo.ts` dispatches one target per loop iteration, returns as soon as one responds, and +advances only after a retryable failure. An operator reading the old text would budget fan-out +cost and latency for something that never happens. + +### #5215 — hand-copied registry values (delivered) + +Thirteen presets restate a byte ceiling and a row ceiling in eight guides, checked by nobody. +Each value is now read from that preset's `modelDiscovery`. + +Two details worth keeping: sections are located by brand name plus the presence of a `KiB`/`MiB` +token rather than by a translated phrase, because a restated anchor is the same hand-copied value +the guard exists to remove; and the byte ceiling is compared as an exact token set, so a stale +number left beside the current one fails instead of passing on a substring. + +The guard immediately earned its keep. `structure/ops/docs-and-release.md` asserted in prose that +the guides carry the same limits. That was false when it was written: the Korean guide had no +Featherless section, so it documented twelve of the thirteen limited presets. The section is +added and the prose is replaced by a description of what is actually asserted. + +### #4560 and #5068 — the two workspace pull requests (analysed, not merged) + +The instruction was to review the actual difference and consolidate only duplicated screens. They +are not the same feature and must not be treated as one. + +- 46 files and 39 files, intersecting in 31. Only **nine** of those 31 are byte-identical. +- **#4560** is the UI foundation: responsive grid, dual collapsible rails, unified filter, + Cockpit Tools import, the quota-analysis regression suite and its layout registrations. +- **#5068** is the pool follow-up: generic pool enablement and strategy persistence, strategy + preview, per-account quota refresh, remaining-token estimates calibrated from request logs, + plan badges, switch notifications, modal focus trapping. + +Three findings decide the sequencing, and none of them is "they overlap": + +1. **#5068 removes behaviour #4560 keeps.** Its `ProviderAccountCard.tsx` drops the Grok coupon + badge and the `ProviderAccountQuota` fallback. Landing #5068 after #4560 would silently + revert them. +2. **#5068 changes an email-masking decision.** `account-quota-analysis.ts` adds `rawEmail` and + prefers an unmasked value, where #4560 deliberately uses the management API's projected + email. That is a privacy boundary, not a display preference, and it needs explicit review + against the `emailMaskingEnabled` policy before either version lands. +3. **#5068 cannot land as it stands.** It folds the collapsed-sidebar CSS into + `gui/src/styles.css`, which carries a committed cap of 2,958 lines; its head is 3,186. The + ratchet only moves downward, so the remedy is the move #4560 already makes — a sibling + `sidebar-collapsed.css` — not a new number. + +Both are 28 commits behind `dev` and conflict on all ten locale catalogs through #5197, and +#4560 additionally conflicts on the two test-layout registries. Neither is a rebase this lane +could carry without absorbing the privacy decision above, so the differential is recorded here +for the coordinator to sequence rather than half-landed. + +### #5016 and #2811 — the Codex CLI update manager (not started here) + +#5016 is phase 2 of #2811 and is an open contributor pull request carrying its own plan/apply +engine. Phase 3 is the dashboard integration. Both are left to their own lane: carrying an +unlanded engine and building its surface in the same branch would put the authorization boundary +#5016 is built around under review twice. + +## Remaining scope, stated rather than closed + +The one src/ defect this lane identified and did not fix: `codexStatus` in +`src/server/management/native-integration-routes.ts` derives `state` from +`config.clientIntegrations?.codex` alone. It reports desired configuration, not what is +currently applied — it does not read `config.toml`, the routing kind, or the catalog pointer, +all of which `src/codex/injected-marker.ts` already exposes predicates for. So the dashboard +cannot answer "what is applied right now", which is half of the completion condition, and it +names no undo path at apply time, which is the disclosure the #5261 state needs. + +The bounded shape of that fix: report the applied routing state from the file rather than from +intent, carry the undo command beside it, and render both on the Codex tab. It is a change to a +DTO that every native client shares plus ten locale catalogs, so it is its own commit set rather +than an addendum to a documentation lane. + +## Verification + +Static source review plus exact-head hosted CI. Local suites, individual tests, typecheck, +build, install and live `ocx` execution were NOT RUN. + +Both delivered changes were simulated statically against the real files before commit rather +than assumed: the discovery-limit guard was run as a Python transcription of its own logic over +all eight guides, which is how the Korean gap surfaced and how the byte and row values were +confirmed to already agree with the registry everywhere else. From 4fc7650af045f6949a45ae5c783032f920614018 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:24:52 +0900 Subject: [PATCH 5/6] fix(gui): key the combo lookup by Map, not by a caller-configured object key A combo's public model id is free-form and operator-configured, and readComboProviders wrote it straight into an object literal. That is a prototype-pollution sink on the write side, and the read side returned an inherited member for an alias of "constructor" or "toString" -- the previous commit guarded the read with Object.hasOwn and left the write as it was. A Map removes both. There is no prototype to shadow, the guard disappears, and the failed-fetch fallback returns an empty Map rather than an empty object, so the two branches keep the same type. --- gui/src/components/CompactionRoutingPanel.tsx | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index 96f42b6295a..ad4eaeb9f80 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -42,10 +42,13 @@ function choiceToTriggers(choice: string): string[] | undefined { * targets (#5216). `parseComboList` is the same reader the combo workspace uses, so the * selector rule lives in one place instead of being spelled out again here. */ -function readComboProviders(payload: unknown): Record { - const result: Record = {}; +function readComboProviders(payload: unknown): Map { + // A Map, not an object: the key is a combo's public model id, which is caller-configured and + // free-form. Writing that into an object literal is a prototype-pollution sink, and reading it + // back would return an inherited member for an alias of `constructor` or `toString`. + const result = new Map(); for (const combo of parseComboList(payload)) { - result[combo.model] = [...new Set(combo.targets.map(target => target.provider).filter(Boolean))]; + result.set(combo.model, [...new Set(combo.targets.map(target => target.provider).filter(Boolean))]); } return result; } @@ -82,7 +85,7 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const [busy, setBusy] = useState(false); const [loadError, setLoadError] = useState(false); const [feedback, setFeedback] = useState<"saved" | "failed" | null>(null); - const [comboProviders, setComboProviders] = useState>({}); + const [comboProviders, setComboProviders] = useState>(() => new Map()); const active = useRef(false); const pending = useRef | null>(null); @@ -102,7 +105,8 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model const response = await fetch(`${apiBase}/api/settings`, { signal: request.signal }); const value = readSetting(await requireJson(response)); if (active.current && pending.current === request) accept(value); - const combos = await fetch(`${apiBase}/api/combos`, { signal: request.signal }).then(requireJson).then(readComboProviders).catch(() => ({})); + const combos = await fetch(`${apiBase}/api/combos`, { signal: request.signal }) + .then(requireJson).then(readComboProviders).catch(() => new Map()); if (active.current && pending.current === request) setComboProviders(combos); } catch { if (active.current && pending.current === request) setLoadError(true); @@ -167,10 +171,8 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model || effort !== (saved?.reasoningEffort ?? "") || triggers !== triggersToChoice(saved?.triggers); // Ask what the selection resolves to instead of reading its name. An aliased combo answers - // here exactly like a prefixed one (#5216). `Object.hasOwn` because a combo id is free-form: - // an alias of `constructor` or `toString` would otherwise read an inherited member and be - // joined as if it were a target list. - const comboTargets = Object.hasOwn(comboProviders, model) ? comboProviders[model] : undefined; + // here exactly like a prefixed one (#5216). + const comboTargets = comboProviders.get(model); // The canonical prefix stays a combo signal of its own. It is the only one left when // /api/combos has not answered yet or failed, and losing it there would describe a combo as // an ordinary provider named "combo" — worse than the alias gap this fixes. From e701a0672389b38fe9dc89bf9c620da0a16dcb81 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 20 Sep 2026 17:53:51 +0900 Subject: [PATCH 6/6] fix(gui): build the combo target list in one pass React Doctor's js-flatmap-filter fired on the map().filter(Boolean) this lane introduced at CompactionRoutingPanel.tsx:51 -- one new warning in one file, and the job's blocking threshold is warning. flatMap does the same work in a single pass. The related js-combine-iterations rule is switched off in gui/doctor.config.json, but this is a different rule and is enabled, so this is a real new finding rather than an accepted one. --- gui/src/components/CompactionRoutingPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gui/src/components/CompactionRoutingPanel.tsx b/gui/src/components/CompactionRoutingPanel.tsx index ad4eaeb9f80..6875b7ab3fa 100644 --- a/gui/src/components/CompactionRoutingPanel.tsx +++ b/gui/src/components/CompactionRoutingPanel.tsx @@ -48,7 +48,7 @@ function readComboProviders(payload: unknown): Map { // back would return an inherited member for an alias of `constructor` or `toString`. const result = new Map(); for (const combo of parseComboList(payload)) { - result.set(combo.model, [...new Set(combo.targets.map(target => target.provider).filter(Boolean))]); + result.set(combo.model, [...new Set(combo.targets.flatMap(target => target.provider ? [target.provider] : []))]); } return result; }