Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions devlog/_plan/260920_meaning_preservation_batch/020_lane_g.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions docs-site/src/content/docs/ko/guides/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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가 공개되어 있으므로
Expand Down
49 changes: 31 additions & 18 deletions gui/src/components/CompactionRoutingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 { comboModelId, parseComboList } from "../combo-workspace-data";
import { formatNamespacedModelId } from "../provider-icons";

type Setting = { model: string; reasoningEffort?: string; triggers?: string[] } | null;
Expand Down Expand Up @@ -32,17 +33,22 @@ function choiceToTriggers(choice: string): string[] | undefined {
return undefined;
}

function readComboProviders(payload: unknown): Record<string, string[]> {
const combos = (payload as { combos?: unknown })?.combos;
if (!Array.isArray(combos)) return {};
const result: Record<string, string[]> = {};
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)];
/**
* 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): Map<string, string[]> {
// 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<string, string[]>();
for (const combo of parseComboList(payload)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Index aliased combos under their canonical selector too

When compactionRouting.model is the canonical combo/<id> for a combo that also has a public alias, /api/combos returns only the alias in combo.model, so this map has no entry for the saved canonical selector. That selector remains valid by design (resolveComboId explicitly accepts canonical IDs for backward compatibility), and combo edits do not migrate compactionRouting; the prefix fallback therefore recognizes the combo but displays “its configured target providers” instead of the actual providers, regressing the disclosure that previously worked for canonical IDs. Index each row by both combo.model and comboModelId(combo.id).

AGENTS.md reference: gui/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

result.set(combo.model, [...new Set(combo.targets.flatMap(target => target.provider ? [target.provider] : []))]);
}
return result;
}
Expand Down Expand Up @@ -79,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<Record<string, string[]>>({});
const [comboProviders, setComboProviders] = useState<Map<string, string[]>>(() => new Map());
const active = useRef(false);
const pending = useRef<ReturnType<typeof createBoundedFetch> | null>(null);

Expand All @@ -99,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<string, string[]>());
if (active.current && pending.current === request) setComboProviders(combos);
} catch {
if (active.current && pending.current === request) setLoadError(true);
Expand Down Expand Up @@ -163,10 +170,16 @@ 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.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.
const isCombo = comboTargets !== undefined || model.startsWith(comboModelId(""));
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 = isCombo ? "" : (namespace || model);
const providers = comboTargets?.join(", ") || t("compactionRouting.comboProvidersUnknown");
const routesAutomatic = triggers !== "manual";

return (
Expand Down Expand Up @@ -195,10 +208,10 @@ function CompactionRoutingControls({ apiBase, models }: { apiBase: string; model
</button>
</div>
</div>
{provider && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {combo
{model && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {isCombo
? t("compactionRouting.comboWarning", { combo: model, providers })
: t("compactionRouting.providerWarning", { provider })}</div>}
{provider && routesAutomatic && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {t("compactionRouting.autoNotice")}</div>}
{model && routesAutomatic && <div className="notice-warn" role="note" style={{ marginTop: 12 }}><IconAlert width={14} /> {t("compactionRouting.autoNotice")}</div>}
{loadError && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("compactionRouting.loadFailed")} <button type="button" className="btn btn-ghost btn-sm" onClick={() => { void load(); }}>{t("common.retry")}</button></div>}
{feedback === "failed" && <div className="notice notice-err" role="alert" style={{ marginTop: 12, marginBottom: 0 }}>{t("compactionRouting.saveFailed")}</div>}
{feedback === "saved" && <div className="muted setting-hint" role="status">{t("compactionRouting.saved")}</div>}
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,7 @@ export const de: Record<TKey, string> = {
"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.",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid claiming configured-order selection for every combo

For combos using round-robin, random, least-used, or reset-window, this warning is inaccurate: pickComboTarget in src/combos/resolve.ts chooses the first target using weight, state, usage, or quota-reset data rather than the displayed configured order. Because the same copy is rendered for every strategy, operators can be told that (provider-a, provider-b) is attempted in that order when the request may start with provider-b; use strategy-neutral wording or include the combo strategy in the warning.

AGENTS.md reference: gui/AGENTS.md:L9-L10

Useful? React with 👍 / 👎.

"compactionRouting.comboProvidersUnknown": "its configured target providers",
"compactionRouting.loadFailed": "Could not load compaction settings.",
"compactionRouting.saved": "Compaction settings saved.",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@ export const fr: Record<TKey, string> = {
"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.",
Expand Down
Loading
Loading