Skip to content
26 changes: 26 additions & 0 deletions docs-site/src/content/docs/guides/claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,32 @@ backup and replacement; if it detects a concurrent edit, it refuses with `deskto
Review the edit and reapply. If restoring the profile also fails after a metadata error, the
reported reason includes both failures; reconcile the profile and metadata before retrying.

#### Choose which models Claude Desktop lists

**Connect → Claude Desktop → Desktop model list** has a checkbox for each model. Search by name or
filter by provider, then use **Select all matching** or **Clear all matching** to change every match,
including rows behind **Show more**. Click
**Save & apply** to write the list to Desktop. Only Claude Desktop's list changes: provider model
settings, Claude Code, Codex and other clients keep every model.

**Newly discovered models** sets what happens when a provider later adds a model. **List
automatically** adds it to Desktop. **Keep hidden until selected** leaves it out until you check it.
The selection is saved in `claudeCode.desktopModelVisibility` and survives catalog refreshes and
reapplies. Tier families under **Advanced** stay optional and do not affect which models are shown.
A hidden model keeps its Desktop id, so a conversation that is already using it keeps working.

The card shows the selected, available and applied counts separately. A selected model that is no
longer available is counted on its own and is not written. If the selection leaves no available
models, the writer falls back to every available model, including normally hidden routes. Update
the selection and apply again to narrow it. Before you apply, the card previews the model IDs the
selected profile file will gain and lose. Its applied count reads that file; it does not verify
which models the Desktop process has loaded. The status bar shows **Saved — apply to update Desktop**
until the saved list reaches the profile. Restart Desktop to load profile changes.
Applying replaces the model list in OpenCodex's Desktop profile,
including a list edited there by hand. Discovery-only mode (`--discovery-only`) writes no list, so
it does not use this selection. The dashboard will not save a selection that hides every
available model.

### First-party (opt-in)

:::caution[Account risk]
Expand Down
2 changes: 1 addition & 1 deletion docs-site/src/content/docs/guides/web-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ Usage heatmap days have one Tab entry point. Use Up/Down for adjacent days and L
shows **No changes** or **Unsaved changes**, with **Revert** to discard edits and **Save** to commit
them. Both switches apply immediately; **Save** never changes them. When interception is stopped, the page shows why and offers **Start interception**, which starts it in place without restarting OpenCodex. The former Code, Settings, and Account sub-tabs are gone; everything the read-only Settings view showed except the intercept port number is on this page.

**Claude Desktop** is its own Connect tab at `#claude/desktop`. It contains the Desktop mode picker and model families. One status row shows whether Claude Desktop runs the profile and whether it is saved, with **Save** and **Save & apply** beside it.
**Claude Desktop** is its own Connect tab at `#claude/desktop`. It contains the Desktop mode picker, a per-model list of what Claude Desktop shows (see [Choose which models Claude Desktop lists](/guides/claude-code/#choose-which-models-claude-desktop-lists)), and model families under **Advanced**. One status row shows whether Claude Desktop runs the profile, with a separate chip for whether dashboard edits are saved, and **Save** and **Save & apply** beside it.

Claude/Anthropic accounts are managed only on **Providers > Anthropic > Accounts**: login and the browser option, the account roster with switch, pause, remove, and reauthentication, the paste-code field, account pool settings, and quota. Claude no longer has an Account sub-tab. Provider-level controls such as the default provider, removal, and the enabled switch stay on Providers.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ These settings govern `/v1/messages`, `/v1/messages/count_tokens`, the `ocx clau
| `claudeCode.authModeMigratedAt?` | `string` | unset | Internal one-time upgrade marker. Do not set manually. |
| `claudeCode.classifierModel?` | `string` | unset | Explicit target for Claude Code Auto Mode classifier turns, as a qualified `provider/model` (for example `RelayA/claude-opus-5`). Auto Mode sends bare safety checks such as `claude-opus-5` with no provider, so without this they fall through to `defaultProvider` — which may not speak Anthropic at all. Nothing is inferred automatically: only a target you declare here is used. |
| `claudeCode.classifierFallbacks?` | `string[]` | unset | Ordered classifier targets used when `classifierModel` is not set. Same qualified `provider/model` form; the first usable entry wins. An explicit `modelMap` entry for the classifier model still outranks both. |
| `claudeCode.desktopModelVisibility?` | `{ mode: "include" \| "exclude"; routes: string[] }` | unset | Which `provider/model` routes the Claude Desktop gateway list carries. `include` lists only `routes` (new models stay hidden); `exclude` lists all but `routes` (new models appear). Unset lists every available model. If the selection leaves no available models, the writer falls back to every available model, including normally hidden routes; update the selection to narrow it again. Affects only Claude Desktop's static and hybrid lists; set it from **Connect → Claude Desktop**. |
| `claudeCode.subagentEffort?` | `"low" \| "medium" \| "high" \| "xhigh" \| "max"` | inherit | Effort written to generated `~/.claude/agents/ocx-*.md`; separate from Codex guidance and proxy caps. Restart through `ocx claude` to regenerate. |

The compatibility policy applies to Claude Code, Desktop and other clients using translated
Expand Down
4 changes: 2 additions & 2 deletions docs-site/src/content/docs/reference/management-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,12 @@ route-specific results rather than repeating this table.
| `POST /api/grok/reset-coupons/consume` | Redeem an eligible reset coupon. Body `{ accountId?, tokenId?, operationId? }`. Optional `operationId` (UUIDv4) makes redemption idempotent: repeating the same ID replays the durable result without double-redemption. | 400 invalid JSON/UUID; 401 unauthenticated; 409 `operation_id_owned_by_another_account` / `coupon_unavailable` / `operation_token_mismatch` / `operation_state_changed` / `attempt_in_progress` / `attempt_unresolved`; 502 upstream error; 503 ledger capacity / `ledger_unavailable`; 500 `attempt_mark_failed` |
| `GET /api/anthropic/reset-grants?accountId=...` | Read the Claude usage-limit reset grants of one Anthropic OAuth account: eligibility, each grant's resets left, validity window, and the windows it clears, plus any unconfirmed attempt still retryable | 400 no matching account; 401 re-authentication needed; 502 upstream unavailable |
| `POST /api/anthropic/reset-grants/consume` | Spend one reset grant. Body `{ accountId, grantId, operationId }`; `operationId` is a UUIDv4 sent upstream as the request ID, so repeating it retries the same claim. Requires a dashboard session. A settled `reset` lifts the account's local cooldown only when it is a quota refusal recorded before the claim was first journaled and the windows the grant reported cleared cover every window that refusal named (both windows when it named none). A refusal recorded while the claim was in flight, an entitlement refusal, or an uncovered window stays, including on a same-id replay. The cached quota is dropped and usage is reread with a fresh request, so the pool can select the account before the old deadline. Model-family exclusions stay until that reread proves a family is no longer rejected. | 400 invalid body; 401 re-authentication needed; 403 `session_required`; 409 `grant_not_usable`, `in_flight`, `unresolved_prior_operation`, `unknown_outcome_expired`, `operation_identity_mismatch`; 500 `journal_write_failed`; 502 `unknown_outcome`; 503 journal busy, unavailable, or full |
| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile | 400 invalid or unavailable assignment |
| `GET, PUT /api/claude-desktop` | Read or persist the Claude Desktop routed/native profile and its model visibility. PUT requires a valid `profile`, including when setting or clearing `visibility` (`visibility`: omit to keep, `null` to clear); a visibility-only body returns 400 | 400 missing/invalid profile, invalid or unavailable assignment, invalid visibility, or a visibility that hides every available model; 409 profile or supplied visibility changed during discovery/save |
| `POST /api/claude-desktop/apply` | Write the saved profile to Claude Desktop's managed config | 400/500 write failure |
| `GET, POST /api/codex/desktop-compatibility/certificate` | Inspect or explicitly prepare Windows compatibility certificate trust | 403 paired local dashboard required for POST; 409 stale, busy, or incomplete state |
| `GET, POST /api/codex/desktop-compatibility/runtime` | Inspect or explicitly start, stop, launch, or run a bounded native compatibility trial | 403 paired local dashboard required for POST; 409 unsupported build, untrusted certificate, or incomplete state |
| `GET, POST /api/codex/desktop-compatibility/settings` | Read or save the next-start observation preference with its current revision | 403 paired local dashboard required for POST; 409 stale, invalid, or unconfirmed configuration |
| `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile and Desktop health | 400 status read failure |
| `GET /api/claude-desktop/status` | Inspect saved-versus-applied profile, Desktop health, and `appliedModels: { name: string; label: string }[] \| null`. This is the selected profile's on-disk static list, not a verified observation of the running Desktop process; `null` means no applied gateway profile or no readable static list (including discovery-only mode), not an empty list | 400 status read failure |
| `GET, PUT /api/claude-code` | Read or update Claude Code gateway, auth-mode, model-map, context, agent, and sidecar settings | 400 invalid field or shape |

`tokenId`, when supplied, must be a non-empty string; invalid values return `400 invalid_token_id` before credentials are read or the ledger is opened. Within the 30-day retention window, a same-`operationId` retry that omits `accountId` uses the recorded account, even if the active account changed. An executable `open` retry that omits `tokenId` uses its recorded token. An explicit different account or token is still refused. A definitive failure clears a matching speculative pending hold in the dashboard, allowing a new explicitly confirmed attempt; actual unknown outcomes remain held.
Expand Down
172 changes: 172 additions & 0 deletions gui/src/components/ClaudeDesktopVisibility.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useMemo, useState, type ReactNode } from "react";
import { useI18n } from "../i18n/shared";
import { Notice } from "../ui";
import { LANE_PAGE } from "../pages/claude-desktop-lane";
import {
applyPreview,
desiredDesktopList,
filterVisibilityModels,
providerOf,
providersOf,
routeVisible,
setNewModelPolicy,
setRoutesVisible,
visibilityCounts,
type AppliedDesktopModel,
type DesktopVisibility,
type ListedDesktopModel,
type VisibilityModel,
} from "../pages/claude-desktop-visibility";

/** How many labels the apply preview names before summarizing the rest as a count. */
const PREVIEW_NAMES = 6;

function previewText(labels: string[]): string {
const shown = labels.slice(0, PREVIEW_NAMES).join(", ");
return labels.length > PREVIEW_NAMES ? `${shown} +${labels.length - PREVIEW_NAMES}` : shown;
}

/**
* Per-model Claude Desktop visibility (#7702): which rows the Desktop 3P list carries.
* Separate from the tier lanes under Advanced; the parent owns the draft and saves it with
* the profile, so Save and Save & apply cover both.
*/
export default function ClaudeDesktopVisibility<T extends VisibilityModel>({
models,
visibility,
onChange,
rendered,
appliedModels,
disabled,
badges,
}: {
/** Rows in display order (the page puts the role holders first). */
models: readonly T[];
visibility: DesktopVisibility | null;
onChange: (next: DesktopVisibility | null) => void;
/** The full list the next apply would write before visibility, in written order. */
rendered: readonly ListedDesktopModel[];
/** What Desktop runs now; null when there is no static list to compare against. */
appliedModels: readonly AppliedDesktopModel[] | null;
disabled: boolean;
badges: (model: T) => ReactNode;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [provider, setProvider] = useState("");
const [limit, setLimit] = useState(LANE_PAGE);
const providers = useMemo(() => providersOf(models), [models]);
const shown = useMemo(() => filterVisibilityModels(models, query, provider), [models, query, provider]);
const counts = visibilityCounts(models, visibility);
const preview = applyPreview(desiredDesktopList(rendered, visibility), appliedModels);
const shownRoutes = shown.map(model => model.route);
const policy = visibility?.mode ?? "exclude";

return (
<div className="claude-visibility">
<div className="claude-visibility-counts" role="status">
<span className="claude-visibility-count">{t("claudeDesktop.visibility.selected", { n: counts.selected })}</span>
<span className="claude-visibility-count">{t("claudeDesktop.visibility.available", { n: counts.available })}</span>
<span className="claude-visibility-count">
{appliedModels ? t("claudeDesktop.visibility.applied", { n: appliedModels.length }) : t("claudeDesktop.visibility.appliedUnknown")}
</span>
{counts.selectedUnavailable > 0 && (
<span className="claude-visibility-count muted">{t("claudeDesktop.visibility.selectedUnavailable", { n: counts.selectedUnavailable })}</span>
)}
</div>

<fieldset className="claude-visibility-policy" disabled={disabled}>
<legend>{t("claudeDesktop.visibility.newModels")}</legend>
{(["exclude", "include"] as const).map(mode => (
<label key={mode}>
<input
type="radio"
name="claude-desktop-new-models"
value={mode}
checked={policy === mode}
onChange={() => onChange(setNewModelPolicy(visibility, models.map(model => model.route), mode))}
/>
{mode === "exclude" ? t("claudeDesktop.visibility.newShow") : t("claudeDesktop.visibility.newHide")}
</label>
))}
</fieldset>

<div className="claude-visibility-filters">
<input
className="input"
type="search"
placeholder={t("claudeDesktop.visibility.search")}
aria-label={t("claudeDesktop.visibility.search")}
value={query}
onChange={event => { setQuery(event.target.value); setLimit(LANE_PAGE); }}
/>
<select
className="input"
aria-label={t("claudeDesktop.visibility.provider")}
value={provider}
onChange={event => { setProvider(event.target.value); setLimit(LANE_PAGE); }}
>
<option value="">{t("claudeDesktop.visibility.allProviders")}</option>
{providers.map(name => <option key={name} value={name}>{name}</option>)}
</select>
<button type="button" className="btn btn-ghost btn-sm" disabled={disabled || shown.length === 0}
onClick={() => onChange(setRoutesVisible(visibility, shownRoutes, true))}>
{t("claudeDesktop.visibility.selectShown")}
</button>
<button type="button" className="btn btn-ghost btn-sm" disabled={disabled || shown.length === 0}
onClick={() => onChange(setRoutesVisible(visibility, shownRoutes, false))}>
{t("claudeDesktop.visibility.clearShown")}
</button>
</div>

{counts.selected === 0 && counts.available > 0 && <Notice tone="warn">{t("claudeDesktop.visibility.noneSelected")}</Notice>}

{shown.length === 0 ? (
<p className="claude-desktop-list-hint">{t("claudeDesktop.visibility.noMatch")}</p>
) : (
<ul className="card claude-desktop-list-rows">
{shown.slice(0, limit).map(model => {
const visible = routeVisible(visibility, model.route);
return (
<li key={model.route} className={`claude-desktop-list-row${visible ? "" : " hidden-row"}`}>
<input
type="checkbox"
checked={visible}
disabled={disabled}
aria-label={t("claudeDesktop.visibility.toggle", { model: model.label })}
onChange={event => onChange(setRoutesVisible(visibility, [model.route], event.target.checked))}
/>
<span className="claude-desktop-list-names">
<strong title={model.label}>{model.label}</strong>
{model.label !== model.route && <code title={model.route}>{model.route}</code>}
</span>
<span className="badge badge-muted claude-visibility-provider">{providerOf(model.route)}</span>
{badges(model)}
</li>
);
})}
</ul>
)}
{shown.length > limit && (
<button type="button" className="btn btn-ghost btn-sm claude-lane-more" onClick={() => setLimit(current => current + LANE_PAGE)}>
{t("models.showMore", { n: shown.length - limit })}
</button>
)}

{preview && (
<div className="claude-visibility-preview">
<span className="claude-visibility-preview-title">{t("claudeDesktop.visibility.previewTitle")}</span>
{preview.added.length === 0 && preview.removed.length === 0
? <span>{t("claudeDesktop.visibility.previewSame")}</span>
: (
<>
{preview.added.length > 0 && <span className="claude-visibility-added">{t("claudeDesktop.visibility.previewAdds", { models: previewText(preview.added) })}</span>}
{preview.removed.length > 0 && <span className="claude-visibility-removed">{t("claudeDesktop.visibility.previewRemoves", { models: previewText(preview.removed) })}</span>}
</>
)}
</div>
)}
<p className="claude-desktop-list-hint">{t("claudeDesktop.visibility.replaceNote")}</p>
</div>
);
}
Loading
Loading